Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/fixtures/smoke-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
}
}
},
"mcp": {
"disabledBuiltins": ["context7", "grep.app", "exa"],
"servers": {}
},
"profiles": {
"principal": {
"model": "smoke:test"
Expand Down
416 changes: 416 additions & 0 deletions .github/scripts/smoke-compiled-binary.ts

Large diffs are not rendered by default.

30 changes: 1 addition & 29 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,32 +46,4 @@ jobs:
run: bun run build

- name: Smoke test compiled binary
env:
ARCHCODE_PORT: 41967
run: |
mkdir -p ~/.archcode
install -m 600 .github/fixtures/smoke-config.json ~/.archcode/config.json
./dist/archcode > "$RUNNER_TEMP/archcode-smoke.log" 2>&1 &
server_pid=$!
cleanup() {
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
}
trap cleanup EXIT

ready=0
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if curl --silent --fail http://127.0.0.1:41967/api/health > "$RUNNER_TEMP/archcode-health.json"; then
ready=1
break
fi
sleep 1
done

if [ "$ready" -ne 1 ]; then
sed -n '1,160p' "$RUNNER_TEMP/archcode-smoke.log"
exit 1
fi

curl --silent --fail http://127.0.0.1:41967/ > "$RUNNER_TEMP/archcode-index.html"
bun -e 'const html = await Bun.file(process.argv[1]).text(); if (!html.includes("<div id=\"root\"></div>")) process.exit(1)' "$RUNNER_TEMP/archcode-index.html"
run: bun .github/scripts/smoke-compiled-binary.ts
39 changes: 3 additions & 36 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -181,42 +181,9 @@ jobs:
"$binary" --help | grep -F "Usage: archcode [options]"
"$binary" --help | grep -F -- "--port <port>"

smoke_home="$RUNNER_TEMP/archcode-home"
mkdir -p "$smoke_home/.archcode"
install -m 600 .github/fixtures/smoke-config.json "$smoke_home/.archcode/config.json"

HOME="$smoke_home" "$binary" --port 41967 > "$RUNNER_TEMP/archcode-smoke.log" 2>&1 &
server_pid=$!
cleanup() {
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
}
trap cleanup EXIT

ready=0
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if curl --silent --fail http://127.0.0.1:41967/api/health > "$RUNNER_TEMP/archcode-health.json"; then
ready=1
break
fi
sleep 1
done

if [[ "$ready" -ne 1 ]]; then
sed -n '1,160p' "$RUNNER_TEMP/archcode-smoke.log"
exit 1
fi

bun -e '
const health = await Bun.file(process.argv[1]).json();
if (health.ok !== true || health.version !== process.argv[2]) process.exit(1);
' "$RUNNER_TEMP/archcode-health.json" "$VERSION"

curl --silent --fail http://127.0.0.1:41967/ > "$RUNNER_TEMP/archcode-index.html"
bun -e '
const html = await Bun.file(process.argv[1]).text();
if (!html.includes("<div id=\"root\"></div>")) process.exit(1);
' "$RUNNER_TEMP/archcode-index.html"
ARCHCODE_SMOKE_BINARY="$binary" \
ARCHCODE_SMOKE_EXPECTED_VERSION="$VERSION" \
bun .github/scripts/smoke-compiled-binary.ts

- name: Upload target archive
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const mockRuntime = {
subscribeSessionRuntimeChanges: mock(() => () => undefined),
subscribeMcpStatusChanges: mock(() => () => undefined),
subscribeModelRuntimeChanges: mock(() => () => undefined),
subscribeProjectCatalogChanges: mock(() => () => undefined),
getMcpServerStatus: mock(() => ({ servers: {} })),
getMcpServerInventory: mock(() => ({ servers: {} })),
} as unknown as AgentRuntime;
Expand Down Expand Up @@ -95,4 +96,18 @@ describe("createRuntimeApp", () => {
expect(observed[0]).toEqual({ type: "model_runtime.changed", revision: "revision-2", createdAt: 2 });
unsubscribe();
});

test("bridges project catalog changes", () => {
let listener: ((event: Extract<GlobalSSEEvent, { type: "project.catalog_changed" }>) => void) | undefined;
const runtime = {
...mockRuntime,
subscribeProjectCatalogChanges: mock((next: typeof listener) => { listener = next; return () => undefined; }),
} as unknown as AgentRuntime;
const observed: GlobalSSEEvent[] = [];
const unsubscribe = globalEventBus.subscribe((event) => observed.push(event));
createRuntimeApp(runtime);
listener!({ type: "project.catalog_changed", createdAt: 3 });
expect(observed[0]).toEqual({ type: "project.catalog_changed", createdAt: 3 });
unsubscribe();
});
});
8 changes: 8 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export function createRuntimeApp(
wireSessionRuntimeBridge(serverRuntime, globalEventBus);
wireMcpStatusBridge(serverRuntime, globalEventBus);
wireModelRuntimeBridge(serverRuntime, globalEventBus);
wireProjectCatalogChangeBridge(serverRuntime, globalEventBus);
wireResourceChangeBridge(serverRuntime, globalEventBus);

return { app, runtime: serverRuntime };
Expand Down Expand Up @@ -166,6 +167,13 @@ function wireModelRuntimeBridge(
runtime.subscribeModelRuntimeChanges((event) => bus.emit(event));
}

function wireProjectCatalogChangeBridge(
runtime: AgentRuntime,
bus: { emit(event: GlobalSSEEvent): void },
): void {
runtime.subscribeProjectCatalogChanges?.((event) => bus.emit(event));
}

function wireResourceChangeBridge(
runtime: AgentRuntime,
bus: { emit(event: GlobalSSEEvent): void },
Expand Down
23 changes: 22 additions & 1 deletion apps/server/src/routes/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { join, resolve } from "node:path";
import type { AgentRuntime } from "@archcode/agent-core";
import { ProjectRegistry, ProjectRuntimeActiveError, silentLogger } from "@archcode/agent-core";
import type { ProjectInfo } from "@archcode/agent-core";
import type { GlobalSSEEvent } from "@archcode/protocol";
import type { GlobalSSEEvent, GlobalSSEProjectCatalogChangedEvent } from "@archcode/protocol";
import { createRuntimeApp } from "../app";
import { globalEventBus } from "../events/global-event-bus";

Expand Down Expand Up @@ -76,6 +76,10 @@ function createTestRuntime(
},
subscribeHitlEvents: () => () => undefined,
subscribeSessionRuntimeChanges: () => () => undefined,
subscribeProjectCatalogChanges: (listener: (event: GlobalSSEProjectCatalogChangedEvent) => void) => projectRegistry.subscribeCatalogChanges(() => listener({
type: "project.catalog_changed",
createdAt: Date.now(),
})),
createSession: async () => ({ sessionId: "session", title: null, createdAt: Date.now(), messages: [], steps: [], todos: [], reminders: [] }),
getSessionFile: async (_workspaceRoot: string, sessionId: string) => ({ sessionId, title: null, createdAt: Date.now(), messages: [], steps: [], todos: [], reminders: [] }),
listSessions: async () => [],
Expand Down Expand Up @@ -185,6 +189,10 @@ describe("projects routes", () => {

expect(response.status).toBe(201);
expect(observed).toEqual([
{
type: "project.catalog_changed",
createdAt: expect.any(Number),
},
{
type: "session.runtime.snapshot",
projectSlugs: ["alpha"],
Expand Down Expand Up @@ -375,6 +383,7 @@ describe("projects routes", () => {
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
expect(events).toEqual([
expect.objectContaining({ type: "project.catalog_changed" }),
expect.objectContaining({ type: "session.runtime.snapshot", projectSlugs: [project.slug], families: [] }),
expect.objectContaining({ type: "hitl.snapshot", projectSlugs: [project.slug], entries: [] }),
]);
Expand Down Expand Up @@ -428,15 +437,21 @@ describe("projects routes", () => {
});
const project = (await created.json()) as ProjectInfo;

const events: GlobalSSEEvent[] = [];
const unsubscribe = globalEventBus.subscribe((event) => events.push(event));
const res = await app.request(`/api/projects/${project.slug}`, {
method: "PATCH",
body: JSON.stringify({ name: "Renamed" }),
headers: { "content-type": "application/json" },
});
unsubscribe();
const body = (await res.json()) as ProjectInfo;

expect(res.status).toBe(200);
expect(body).toEqual({ ...project, name: "Renamed" });
expect(events).toEqual([
expect.objectContaining({ type: "project.catalog_changed" }),
]);
});

test("PATCH /api/projects/:slug rejects unknown body fields", async () => {
Expand Down Expand Up @@ -544,13 +559,19 @@ describe("projects routes", () => {
});
const project = (await created.json()) as ProjectInfo;

const events: GlobalSSEEvent[] = [];
const unsubscribe = globalEventBus.subscribe((event) => events.push(event));
const res = await app.request(`/api/projects/${project.slug}/touch`, { method: "POST" });
unsubscribe();
const body = (await res.json()) as ProjectInfo;

expect(res.status).toBe(200);
expect(body.slug).toBe(project.slug);
expect(typeof body.lastOpenedAt).toBe("string");
expect(body.lastOpenedAt).not.toBe(project.lastOpenedAt);
expect(events).toEqual([
expect.objectContaining({ type: "project.catalog_changed" }),
]);
});

test("POST /api/projects/:slug/touch for non-existent slug returns 404 ProjectNotFoundError", async () => {
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/api/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,20 @@ describe("MCP control actions", () => {

test("loads inventory and reconnects only by saved server identity", async () => {
globalThis.document = { cookie: "" } as Document;
const controller = new AbortController();
const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input) === "/api/mcp/inventory") return jsonResponse({ servers: { local: [] } });
if (String(input) === "/api/mcp/inventory") {
expect(init?.signal).toBe(controller.signal);
return jsonResponse({ servers: { local: [] } });
}
expect(String(input)).toBe("/api/mcp/reconnect/local");
expect(init?.method).toBe("POST");
expect(init?.body).toBeUndefined();
return jsonResponse({ servers: { local: { state: "connecting", startedAt: 1 } } });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;

await expect(getMcpInventory()).resolves.toEqual({ local: [] });
await expect(getMcpInventory({ signal: controller.signal })).resolves.toEqual({ local: [] });
await expect(reconnectMcpServer("local")).resolves.toEqual({ local: { state: "connecting", startedAt: 1 } });
});
});
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/api/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ export async function getMcpStatus(): Promise<McpServerStatusMap> {
return res.servers;
}

export async function getMcpInventory(): Promise<McpServerInventoryResponse["servers"]> {
const response = await apiFetch<McpServerInventoryResponse>("/api/mcp/inventory");
export async function getMcpInventory(
options: { signal?: AbortSignal } = {},
): Promise<McpServerInventoryResponse["servers"]> {
const response = await apiFetch<McpServerInventoryResponse>("/api/mcp/inventory", {
signal: options.signal,
});
return response.servers ?? {};
}

Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/bootstrap/BootstrapGate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ describe("BootstrapGate", () => {

test("opens Config Recovery inside the restricted Settings shell with a terminal grant", async () => {
dom.reconfigure({ url: "http://localhost/config-recovery#token=recovery-token" });
window.localStorage.setItem("archcodeTheme", "dark");
globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input) === "/api/bootstrap") return Response.json({
mode: "config_error",
Expand All @@ -180,7 +181,7 @@ describe("BootstrapGate", () => {
}) as unknown as typeof fetch;

await act(async () => {
root.render(<BootstrapGate><p>Workbench mounted</p></BootstrapGate>);
root.render(<AppRoot><BootstrapGate><p>Workbench mounted</p></BootstrapGate></AppRoot>);
await Promise.resolve();
});

Expand Down
36 changes: 14 additions & 22 deletions apps/web/src/components/bootstrap/BootstrapGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,11 @@ type GateState = { kind: "loading" } | { kind: "error"; message: string } | { ki
const primaryButton = "inline-flex h-9 items-center justify-center gap-2 rounded-sm bg-brand px-4 text-[12px] font-semibold text-brand-ink transition-colors duration-[var(--motion-fast)] hover:bg-brand-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:cursor-not-allowed disabled:opacity-40";
const secondaryButton = "inline-flex h-9 items-center justify-center gap-2 rounded-sm bg-bg-active px-4 text-[12px] font-semibold text-text-secondary transition-colors duration-[var(--motion-fast)] hover:bg-bg-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:cursor-not-allowed disabled:opacity-40";

let terminalGrantFromFragment: string | undefined;

function readTerminalGrant(): string | undefined {
if (terminalGrantFromFragment !== undefined) return terminalGrantFromFragment;
function readTerminalGrantFromFragment(): string | undefined {
if (typeof window === "undefined") return undefined;
const params = new URLSearchParams(window.location.hash.slice(1));
const token = params.get("token")?.trim();
if (token) {
terminalGrantFromFragment = token;
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
}
return terminalGrantFromFragment;
return token || undefined;
}

function emptySetupConfig(): ServerConfigUpdate {
Expand All @@ -49,6 +42,7 @@ export function BootstrapGate({
onAuthInvalidated?: () => void;
}) {
const [state, setState] = useState<GateState>({ kind: "loading" });
const [terminalGrant, setTerminalGrant] = useState(readTerminalGrantFromFragment);
const reload = useCallback(async () => {
setState({ kind: "loading" });
try {
Expand All @@ -61,6 +55,10 @@ export function BootstrapGate({
}, []);

useEffect(() => { void reload(); }, [reload]);
useEffect(() => {
if (terminalGrant === undefined || typeof window === "undefined" || window.location.hash === "") return;
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
}, [terminalGrant]);
useEffect(
() => subscribeAuthInvalidation(() => {
onAuthInvalidated?.();
Expand All @@ -79,16 +77,14 @@ export function BootstrapGate({

const { status } = state;
if (status.mode === "setup") {
const grant = readTerminalGrant();
return grant
? <SetupPage grant={grant} onComplete={reload} />
return terminalGrant
? <SetupPage grant={terminalGrant} onGrantConsumed={() => setTerminalGrant(undefined)} onComplete={reload} />
: <SetupLinkRequiredPage onRetry={reload} />;
}
if (status.mode === "config_error") {
const grant = readTerminalGrant();
return grant
? <ConfigRecoverySettings grant={grant} onTransition={(next) => {
if (next.mode === "ready") setTerminalGrantConsumed();
return terminalGrant
? <ConfigRecoverySettings grant={terminalGrant} onTransition={(next) => {
if (next.mode === "ready") setTerminalGrant(undefined);
normalizeBootstrapPath(next);
setState({ kind: "status", status: next });
}} />
Expand Down Expand Up @@ -178,7 +174,7 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: () => Promise<void> }) {
</BootstrapShell>;
}

function SetupPage({ grant, onComplete }: { grant: string; onComplete: () => Promise<void> }) {
function SetupPage({ grant, onGrantConsumed, onComplete }: { grant: string; onGrantConsumed: () => void; onComplete: () => Promise<void> }) {
const [config, setConfig] = useState<ServerConfigUpdate>(emptySetupConfig);
const [adapterCatalog, setAdapterCatalog] = useState<ProviderAdapterCatalog>();
const [loadingCatalog, setLoadingCatalog] = useState(true);
Expand Down Expand Up @@ -228,7 +224,7 @@ function SetupPage({ grant, onComplete }: { grant: string; onComplete: () => Pro
? { config, requireLogin: true, password }
: { config, requireLogin: false };
await completeSetup(grant, request);
setTerminalGrantConsumed();
onGrantConsumed();
await onComplete();
} catch (cause) {
setFieldErrors(toFieldErrors(cause));
Expand Down Expand Up @@ -262,10 +258,6 @@ function SetupPage({ grant, onComplete }: { grant: string; onComplete: () => Pro
</main>;
}

function setTerminalGrantConsumed() {
terminalGrantFromFragment = undefined;
}

function normalizeBootstrapPath(status: BootstrapStatus): void {
if (typeof window === "undefined") return;
const pathname = window.location.pathname;
Expand Down
Loading