Skip to content
Closed
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
6 changes: 3 additions & 3 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ Service
service status Inspect the login service registration for this home
service update Adopt this runtime when idle; restore the previous one on failure
service recover-update Restore the previous runtime after an interrupted update
status Inspect the machine and recent operations
status Inspect the machine, unfinished work and bounded recent history
storage Inspect image and VM disk usage
doctor Inspect host and runtime prerequisites
logs [--lines <count>] Read the newest service log lines (default 200, at most 1000)
Expand Down Expand Up @@ -197,10 +197,10 @@ Environments
instance reconcile <id> Recheck an interrupted VM safely

Activities and operations
activity list List activities, newest first; filter with --kind, --status, --repository, --limit
activity list List unfinished and recent activities; filter with --kind, --status, --repository, --limit
activity get <id> Inspect one activity with the operations that belong to it
activity cancel <id> Cancel work an activity owns, or close one that only waits for you
operation get <id> Inspect one operation
operation get <id> Inspect one retained operation, including outside the status window
operation wait <id> Wait for an operation's terminal state; on --timeout, print it as it is
operation cancel <id> Cancel an active operation, or close one that only waits for you
command --file <path> Submit any protocol command as JSON
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type DesktopAction =
| "notifications.get"
| "notifications.set"
| "status"
| "activity"
| "storage"
| "doctor"
| "logs"
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,11 @@ else {
case "status":
data = await (await client()).status();
break;
case "activity":
data = await (
await client()
).activity(Schema.decodeUnknownSync(Schema.NonEmptyString)(input));
break;
case "jobs": {
const bindingId = Schema.decodeUnknownSync(Schema.NonEmptyString)(input);
data = await (await client()).jobs(bindingId);
Expand Down
14 changes: 9 additions & 5 deletions apps/desktop/src/renderer/components/activity-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { isGeneratedStageMessage, activeRunnerStage } from "@/lib/runner-stage";
import { cancellableCommands, commandLabels } from "@/lib/operations";
import { MigrationResult } from "@/views/migration-result";
import { PreparationResult } from "@/views/preparation-result";
import { useStateApi } from "@/state";
import { useActivityDetail, useStateApi } from "@/state";

function VectisTile() {
return (
Expand All @@ -31,16 +31,19 @@ function VectisTile() {
}

export function ActivityRow({
activity,
activity: summary,
defaultOpen = false,
}: {
activity: Activity;
defaultOpen?: boolean;
}) {
const { snapshot, submit, perform } = useStateApi();
const operations = (snapshot?.operations ?? []).filter(
(operation) => operation.activityId === activity.id,
);
const [open, setOpen] = useState(defaultOpen);
const detail = useActivityDetail(summary.id, open);
const activity = detail?.activity ?? summary;
const operations =
detail?.operations.toReversed() ??
(snapshot?.operations ?? []).filter((operation) => operation.activityId === activity.id);
// Operations arrive newest first; an activity reads as the story of what happened.
const steps = [...operations].reverse();
const deciding =
Expand Down Expand Up @@ -88,6 +91,7 @@ export function ActivityRow({
return (
<ExpandableRow
defaultOpen={defaultOpen}
onOpenChange={setOpen}
leading={
activity.kind === "github" ? (
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-muted text-foreground/80">
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/renderer/components/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ export function ExpandableRow({
aside,
actions,
defaultOpen = false,
onOpenChange,
children,
}: {
id?: string;
Expand All @@ -279,11 +280,13 @@ export function ExpandableRow({
aside?: ReactNode;
actions?: ReactNode;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children: ReactNode;
}) {
return (
<Collapsible
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
className="rise-in scroll-mt-4"
id={id ? sectionId(id) : undefined}
>
Expand Down
55 changes: 55 additions & 0 deletions apps/desktop/src/renderer/lib/activity-detail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { expect, test } from "vitest";
import type { ActivityDetail, Snapshot } from "../../../../../packages/protocol/src/index.js";
import { mergeActivityDetail } from "./activity-detail.js";

test("opening an old activity restores its omitted steps without losing current machine state", () => {
const detail: ActivityDetail = {
activity: {
id: "old",
kind: "vectis",
subject: { type: "command", command: "job.scan" },
rootOperationId: "root",
status: "succeeded",
message: "Done",
createdAt: "2020-01-01",
updatedAt: "2020-01-02",
},
operations: ["root", "child"].map((id) => ({
id,
key: id,
activityId: "old",
command: "job.scan",
status: "succeeded",
message: "Done",
createdAt: "2020-01-01",
updatedAt: "2020-01-02",
})),
};
const snapshot: Snapshot = {
protocolVersion: 1,
revision: "service:42",
machine: { id: "host", name: "Host", paused: false },
environments: [],
instances: [],
activities: [],
operations: [
{
id: "other",
key: "other",
activityId: "other",
command: "job.scan",
status: "running",
message: "Running",
createdAt: "2026-10-16",
updatedAt: "2026-10-16",
},
],
};
const merged = mergeActivityDetail(snapshot, detail);
expect(merged.activities).toEqual([detail.activity]);
expect(merged.operations.map(({ id }) => id)).toEqual(["other", "child", "root"]);
expect(merged.revision).toBe(snapshot.revision);
expect(merged.machine).toBe(snapshot.machine);
expect(snapshot.activities).toEqual([]);
expect(mergeActivityDetail(merged, detail)).toEqual(merged);
});
15 changes: 15 additions & 0 deletions apps/desktop/src/renderer/lib/activity-detail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { ActivityDetail, Snapshot } from "../../../../../packages/protocol/src/index.js";

export function mergeActivityDetail(snapshot: Snapshot, detail: ActivityDetail): Snapshot {
return {
...snapshot,
activities: [
detail.activity,
...(snapshot.activities ?? []).filter((activity) => activity.id !== detail.activity.id),
],
operations: [
...snapshot.operations.filter((operation) => operation.activityId !== detail.activity.id),
...detail.operations.toReversed(),
],
};
}
38 changes: 37 additions & 1 deletion apps/desktop/src/renderer/state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { createContext, useContext, useEffect, useRef, useState, type ReactNode
import { Schema } from "effect";
import {
commandActivity,
ActivityDetail,
Operation,
Snapshot,
type Command,
} from "../../../../packages/protocol/src/index.js";
import type { DesktopAction } from "../bridge.js";
import { mergeActivityDetail } from "@/lib/activity-detail";
import { activityTitle, openActivity } from "@/lib/activities";
import { notify } from "@/lib/notify";
import { commandLabels, settingLabels } from "@/lib/operations";
Expand All @@ -33,6 +35,7 @@ export async function readJobLogs(input: JobLogRequest, machineId?: string) {
// Reads never change anything, so they never need the views to look again.
const reads: ReadonlySet<DesktopAction> = new Set([
"status",
"activity",
"notifications.get",
"jobs",
"job.logs",
Expand Down Expand Up @@ -93,7 +96,14 @@ export function StateProvider({ children }: { children: ReactNode }) {
const value = Schema.decodeUnknownSync(Snapshot)(
await request("status", undefined, machineId),
);
if (!stopped) setSnapshot(value);
const route = new URL(window.location.hash.slice(1), "http://localhost");
const id = route.pathname === "/activities" ? route.searchParams.get("id") : null;
const detail = id
? await request("activity", id, machineId)
.then((reply) => Schema.decodeUnknownSync(ActivityDetail)(reply))
.catch(() => undefined)
: undefined;
if (!stopped) setSnapshot(detail ? mergeActivityDetail(value, detail) : value);
} catch {
if (!stopped) setSnapshot(null);
}
Expand All @@ -113,11 +123,13 @@ export function StateProvider({ children }: { children: ReactNode }) {
if (document.visibilityState === "visible") void refresh();
};
document.addEventListener("visibilitychange", wake);
window.addEventListener("hashchange", wake);
void refresh();
return () => {
stopped = true;
clearTimeout(timer);
document.removeEventListener("visibilitychange", wake);
window.removeEventListener("hashchange", wake);
};
}, [machineId]);
useEffect(() => {
Expand Down Expand Up @@ -211,3 +223,27 @@ export function useStateApi() {
if (!state) throw new Error("Missing desktop state.");
return state;
}

export function useActivityDetail(id: string, open: boolean) {
const { snapshot, machineId } = useStateApi();
const [detail, setDetail] = useState<{ machineId: string | undefined; value: ActivityDetail }>();
const revision = snapshot?.revision;
useEffect(() => {
if (!open) return;
let stopped = false;
void request("activity", id, machineId)
.then((reply) => {
const value = Schema.decodeUnknownSync(ActivityDetail)(reply);
if (!stopped) setDetail({ machineId, value });
})
.catch(() => {
if (!stopped) setDetail(undefined);
});
return () => {
stopped = true;
};
}, [id, open, machineId, revision]);
return detail?.machineId === machineId && detail?.value.activity.id === id
? detail.value
: undefined;
}
2 changes: 2 additions & 0 deletions apps/docs/docs/guides/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ The skill is a plain [`SKILL.md`](https://github.com/kerddotdev/vectis/blob/main

To let an agent control another Mac, sign in once with `vectis login` and start the server with `vectis-mcp --machine <machine-id>`. A remote session never falls back to the local Mac.

`vectis_status` returns a compact summary: machine and cloud state, runner capacity, environments, non-stopped instances, counts within the status window, and the newest 20 operations and activities with shortened messages. It omits full operation results. Read `vectis_activities` for unfinished activities and the newest 100 terminal activities, `vectis_activity` for one retained activity and all its steps, or `vectis_operation` for a full result. These detail tools query individual records, locally and remotely, and remain available when a record falls outside the status window. Tool results are returned once in `structuredContent`, with an empty `content` array.

## Rules agents follow

- Humans approve pairing, sign-in and GitHub linking in the browser. Agents hand over the link and wait.
Expand Down
4 changes: 4 additions & 0 deletions apps/docs/docs/guides/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ If Vectis refused the assigned job, the cleanup result keeps the original reason

`vectis status --json` groups related operations into `activities`. A preparation keeps one activity across setup attempts; a runner activity includes its VM start and cleanup. Preparation status follows the latest attempt, while a runner follows its root operation. A successful discard cancels the preparation activity; the discard operation itself stays `succeeded`. Operation IDs and request keys stay unchanged.

Status includes every unfinished operation and activity, including `action_required`, plus the newest 100 terminal operations and 100 terminal activities. It includes every non-stopped instance and the newest 20 stopped instances. These are display windows, not the complete retained history. Use `vectis operation get <id> --json` or `vectis activity get <id> --json` for an older retained record; waits poll only the requested operation. `vectis activity list` reads activity summaries directly. The desktop fetches an opened activity's full steps separately.

Stopped instance records older than 24 hours are removed beyond the newest 20, using their stop time when available and creation time for older records. This removes only stopped records, not guest files, unfinished work or request-key receipts. Replaying a retained stop request still returns its original operation; an expired operation's key remains reserved.

Runner activities keep two separate optional facts: `subject.requestedJob` is the job that requested capacity, and `subject.actualJob` is the job observed on that registered runner. GitHub can assign a different job. Each may include `name`, `workflowName` and `htmlUrl` when GitHub provides them; a missing actual job means its identity is still unknown. Job failure does not make a successful runner lifecycle fail, and job success does not hide a cleanup failure.

Idle runners drain after 5 minutes when their requested job no longer needs capacity; manual runners without a requested job use the same window. GitHub reporting offline or missing for 2 consecutive minutes, or continuous status read failures for 15 minutes, stops a listening runner with `runner_offline`. Registration attempts with confirmed cleanup can fail retryably with `runner_registration_failed`; unresolved registration still requires reconciliation.
Expand Down
72 changes: 72 additions & 0 deletions apps/mcp/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createMcpServer } from "./server.js";
import { startService } from "../../server/src/http.js";
import { VectisClient } from "../../../packages/client/src/index.js";
import { VectisError } from "../../../packages/protocol/src/index.js";
import { activityFor } from "../../server/src/activities.js";

test("MCP discovers and executes the same authenticated commands as the CLI client", async () => {
const home = await mkdtemp(join(tmpdir(), "vectis-mcp-"));
Expand Down Expand Up @@ -315,3 +316,74 @@ test("MCP job logs forwards the byte cursor and rejects invalid requests", async
await rm(home, { recursive: true, force: true });
}
});

test("MCP status stays compact with thousands of retained results and returns data only once", async () => {
const home = await mkdtemp(join(tmpdir(), "vectis-mcp-bounded-"));
const service = await startService({ home });
const api = new VectisClient(service.connection);
const server = createMcpServer(async () => api);
const client = new Client({ name: "bounded-status-test", version: "1" });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
try {
const command = { type: "job.scan", bindingId: "binding" } as const;
service.store.db.exec("BEGIN IMMEDIATE");
for (let index = 0; index < 2000; index++) {
const id = `history-${index}`;
const link = activityFor(id, command);
service.store.put("activity", id, link?.create);
service.store.put("operation", id, {
id,
key: id,
command: command.type,
activityId: id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
status: index === 0 ? "action_required" : "succeeded",
result: { largeResult: "x".repeat(4096) },
message: "x".repeat(1024),
});
}
service.store.db.exec("COMMIT");
await server.connect(serverTransport);
await client.connect(clientTransport);
const result = await client.callTool({ name: "vectis_status", arguments: {} });
expect(result.isError).not.toBe(true);
expect(result.content).toEqual([]);
expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan(64 * 1024);
expect(result).toMatchObject({
structuredContent: {
result: {
operations: expect.any(Array),
activities: expect.any(Array),
counts: { operations: { byStatus: { action_required: 1 } } },
details: expect.stringContaining("vectis_operation"),
},
},
});
expect(JSON.stringify(result)).not.toContain("largeResult");
const status = vi.spyOn(api, "status").mockRejectedValue(new Error("Unexpected status read"));
const activities = await client.callTool({
name: "vectis_activities",
arguments: { status: "action_required" },
});
expect(activities).toMatchObject({
content: [],
structuredContent: { result: [{ id: "history-0" }] },
});
expect(status).not.toHaveBeenCalled();
status.mockRestore();
const full = await client.callTool({
name: "vectis_operation",
arguments: { id: "history-0" },
});
expect(full).toMatchObject({
content: [],
structuredContent: { result: { result: { largeResult: expect.any(String) } } },
});
} finally {
await client.close();
await server.close();
await service.close();
await rm(home, { recursive: true, force: true });
}
});
Loading
Loading