Repository navigation
M1 slice 7: amicode service activation wiring — parallel-run boot #475
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| // AMICODE SERVICE wiring (#451, M1) — boot the extension-host amicode service | ||
| // at activation, alongside the fork opencode server (the parallel-run harness: | ||
| // both serve the 31 amicode routes; consumers stay on the fork until the M3 | ||
| // cutover, while the contract tests + dogfood probes hold the port to parity). | ||
| // | ||
| // vscode-free on purpose (the log sink is a structural interface, mirroring | ||
| // the service's own discipline) so the boot/lifecycle logic is unit-testable. | ||
| // | ||
| // Lifecycle notes: | ||
| // - The service is STATELESS across requests (every route reads state at call | ||
| // time via env overrides — same contract as the fork's routes), so unlike | ||
| // the opencode server it needs NO restart on solver-mode switches, config | ||
| // re-preps, or telemetry flips. One boot per activation; dispose stops it. | ||
| // - The password is the service's OWN per-boot mint (server_auth idiom) — | ||
| // deliberately NOT the opencode server's: the two surfaces have separate | ||
| // consumers, and a credential shared with the spawn env would let anything | ||
| // that can read the terminal env hit the chat server's routes too. | ||
| import { createAmicodeService } from "./amicode_service"; | ||
| import type { AmicodeServiceServer } from "./amicode_service/server"; | ||
|
|
||
| /** What consumers (terminal env, future iframe auth, dogfood probes) need. */ | ||
| export interface AmicodeServiceHandle { | ||
| url: string; | ||
| authHeader: string; | ||
| } | ||
|
|
||
| export interface AmicodeServiceBoot extends AmicodeServiceHandle { | ||
| service: AmicodeServiceServer; | ||
| } | ||
|
|
||
| /** | ||
| * Boot the amicode service on an ephemeral loopback port. Never throws past | ||
| * activation wiring: a boot failure is logged and returns undefined — the | ||
| * extension must keep working with the fork server alone (parallel-run means | ||
| * the service is additive, never load-bearing, until the M3 cutover). | ||
| */ | ||
| export async function startAmicodeService(log: { | ||
| appendLine(line: string): void; | ||
| }): Promise<AmicodeServiceBoot | undefined> { | ||
| try { | ||
| const service = createAmicodeService(); | ||
| const url = await service.start(); | ||
| log.appendLine( | ||
| `[amicode-service] parallel-run: listening on ${url.toString()} (31 routes; auth: per-boot Basic)`, | ||
| ); | ||
| return { service, url: url.toString().replace(/\/$/, ""), authHeader: service.authHeader }; | ||
| } catch (err) { | ||
| log.appendLine(`[amicode-service] boot FAILED (continuing without it): ${err}`); | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| /** Dispose wiring for ctx.subscriptions. */ | ||
| export function amicodeServiceDisposal(boot: AmicodeServiceBoot | undefined): { dispose(): void } { | ||
| return { | ||
| dispose() { | ||
| void boot?.service.stop().catch(() => undefined); | ||
| }, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -58,6 +58,7 @@ import { probeCommand, formatHealthReport, type HealthResult } from "./healthche | |
| import { fleetHealthReport, FLEET_GUARD_REL } from "./fleet_health"; | ||
| import { isFleetClient, getFleetRole, goStandalone, readFleetConfig, migrateLegacyFallback } from "./fleet_fallback"; | ||
| import { registerAmicodeTerminal } from "./terminal"; | ||
| import { amicodeServiceDisposal, startAmicodeService } from "./amicode_service_wiring"; | ||
| import { resolveMountStack, personalMount, defaultVaultsRoot } from "./substrate/mount_store"; | ||
| import { initDistillerTransport, triggerRunDistill, triggerSweep, type DistillerSetup } from "./substrate/distiller"; | ||
| import { | ||
|
|
@@ -86,6 +87,7 @@ import { postDeviceStatus, postDeviceActions, postDeviceActivate } from "./inspe | |
| // ============================================================================ | ||
|
|
||
| let serverManager: ServerManager | undefined; | ||
| let amicodeService: { url: string; authHeader: string } | undefined; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Use the exported Both sites duplicate the service connection shape. Import
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| let statusBar: StatusBarManager | undefined; | ||
| let sseClient: OpencodeEventClient | undefined; | ||
| let runsManager: RunsManager | undefined; | ||
|
|
@@ -730,6 +732,14 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> { | |
| }); | ||
| ctx.subscriptions.push({ dispose: () => void serverManager?.stop() }); | ||
|
|
||
| // Amicode service (#451 M1): the extension-host port of the 31 fork | ||
| // amicode routes, booted in PARALLEL-RUN alongside the fork server (the | ||
| // chat/widgets still hit the fork until the M3 cutover). Stateless — no | ||
| // restart coupling with solver-mode switches or config re-preps. | ||
| const serviceBoot = await startAmicodeService(opencodeChannel); | ||
| amicodeService = serviceBoot ?? undefined; | ||
| ctx.subscriptions.push(amicodeServiceDisposal(serviceBoot)); | ||
|
Comment on lines
+735
to
+741
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Start the service outside the local opencode branch. Lines 735-741 run only when Start the service once before the fleet and local-server split. Register its disposal once for every activation path. 🤖 Prompt for AI Agents |
||
|
|
||
| // Solver-mode switcher (rchari/solver-wire): the app's toggle POSTs | ||
| // {status:"switching"}; we do the REAL switch — grant/revoke the issimo | ||
| // entitlement, re-prep the session project (skills/scores/allowlist follow | ||
|
|
@@ -1242,6 +1252,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> { | |
| getConfigContent: () => currentSpawnEnv?.OPENCODE_CONFIG_CONTENT, | ||
| getSpawnEnv: () => currentSpawnEnv, | ||
| channel: opencodeChannel, | ||
| getAmicodeService: () => amicodeService, | ||
| }); | ||
|
|
||
| // Fleet mode — "Go Standalone" per CONTEXT.md (#338). | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| // amicode_service wiring tests (#451, M1) — the activation/lifecycle slice: | ||
| // boot on an ephemeral port, serve a route with the per-boot auth, export the | ||
| // terminal-env handle shape, dispose cleanly, and NEVER throw into activation | ||
| // (a boot failure logs and returns undefined — parallel-run means the service | ||
| // is additive, never load-bearing, until the M3 cutover). | ||
| import { describe, it, expect } from "vitest"; | ||
| import { amicodeServiceDisposal, startAmicodeService } from "../src/amicode_service_wiring"; | ||
|
|
||
| const sinkLog = () => { | ||
| const lines: string[] = []; | ||
| return { lines, log: { appendLine: (l: string) => lines.push(l) } }; | ||
| }; | ||
|
|
||
| describe("startAmicodeService", () => { | ||
| it("boots on an ephemeral loopback port, serves a route with the per-boot auth, and logs the URL", async () => { | ||
| const { lines, log } = sinkLog(); | ||
| const boot = await startAmicodeService(log); | ||
| expect(boot).toBeDefined(); | ||
| if (!boot) return; | ||
| try { | ||
| expect(boot.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); | ||
| expect(boot.authHeader).toMatch(/^Basic /); | ||
| expect(lines.some((l) => l.includes("[amicode-service] parallel-run"))).toBe(true); | ||
| // The service answers (profile route shape — ok:true, fork contract). | ||
| const r = await fetch(`${boot.url}/amicode/profile`, { headers: { Authorization: boot.authHeader } }); | ||
| expect(r.status).toBe(200); | ||
| const body = (await r.json()) as { ok: boolean }; | ||
| expect(body.ok).toBe(true); | ||
| // 401 without the credential — the fork's auth discipline. | ||
| const anon = await fetch(`${boot.url}/amicode/profile`); | ||
| expect(anon.status).toBe(401); | ||
| } finally { | ||
| await boot.service.stop(); | ||
| } | ||
| }); | ||
|
|
||
| it("disposal stops the service (and is a no-op for a failed boot)", async () => { | ||
| const { log } = sinkLog(); | ||
| const boot = await startAmicodeService(log); | ||
| if (!boot) throw new Error("boot unexpectedly failed"); | ||
| const url = boot.url; | ||
| amicodeServiceDisposal(boot).dispose(); | ||
| // The port closes asynchronously — poll briefly for the refusal. | ||
| let closed = false; | ||
| for (let i = 0; i < 20 && !closed; i++) { | ||
| try { | ||
| await fetch(url); | ||
| await new Promise((r) => setTimeout(r, 50)); | ||
| } catch { | ||
| closed = true; | ||
| } | ||
| } | ||
| expect(closed).toBe(true); | ||
| // Failed boot → disposal is a safe no-op. | ||
| expect(() => amicodeServiceDisposal(undefined).dispose()).not.toThrow(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| // amicode_service wiring — failure path (#451, M1): a service boot failure | ||
| // must return undefined (logged), never throw into activation. Separate file | ||
| // because vi.mock is file-hoisted and would replace the real service for the | ||
| // happy-path tests too. | ||
| import { describe, it, expect, vi } from "vitest"; | ||
|
|
||
| vi.mock("../src/amicode_service", () => ({ | ||
| createAmicodeService: () => ({ | ||
| start: () => Promise.reject(new Error("simulated bind failure")), | ||
| authHeader: "Basic should-never-be-reached", | ||
| stop: async () => undefined, | ||
| }), | ||
| })); | ||
|
|
||
| import { startAmicodeService } from "../src/amicode_service_wiring"; | ||
|
|
||
| describe("startAmicodeService — failure path", () => { | ||
| it("returns undefined and logs, never throws into activation", async () => { | ||
| const lines: string[] = []; | ||
| const boot = await startAmicodeService({ appendLine: (l) => lines.push(l) }); | ||
| expect(boot).toBeUndefined(); | ||
| expect(lines.some((l) => l.includes("[amicode-service] boot FAILED"))).toBe(true); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: harmoniqs/amicode
Length of output: 3394
🏁 Script executed:
Repository: harmoniqs/amicode
Length of output: 46815
🏁 Script executed:
Repository: harmoniqs/amicode
Length of output: 14714
Make
AmicodeServiceServer.start()reject listen errors.At
packages/extension/src/amicode_service/server.ts:140,start()resolves only from theserver.listen()callback. A bind failure emits an unhandlederrorevent, so the promise does not reject and the extension host can terminate. Register an error handler beforelisten(), clearthis.serveron failure, rethrow the error, and add a server-level test for the real listen-error path. The existing failure test only mocks a rejectedstart()promise.🤖 Prompt for AI Agents