Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
a2762da
fix(auth): bind GitHub linking to the initiating browser
kerdofficial Oct 9, 2026
9e5b02e
fix(server): keep service state and credentials private
kerdofficial Oct 9, 2026
8f4a676
fix(site): verify font downloads and isolate deployment
kerdofficial Oct 9, 2026
70bf908
fix(github): enforce approval for private fork workflows
kerdofficial Oct 9, 2026
e803fdf
fix(runner): isolate guest networks and SSH identities
kerdofficial Oct 9, 2026
5aaa095
fix(ci): keep PR metadata jobs off Vectis runners
kerdofficial Oct 9, 2026
98b7e2b
fix(ci): use an available Xcode runner for native checks
kerdofficial Oct 9, 2026
cce0d00
test(ci): validate Vectis Ubuntu and macOS environments
kerdofficial Oct 10, 2026
18af643
test(ci): validate Vectis Windows ARM64 environment
kerdofficial Oct 10, 2026
e1d844d
fix(windows): install MinGit outside the standard Git path
kerdofficial Oct 10, 2026
440c9a3
fix(windows): install MinGit outside the standard Git path
kerdofficial Oct 10, 2026
13b8120
fix(runner): preserve exit evidence when VM cleanup fails
kerdofficial Oct 11, 2026
4e384d9
fix(windows): preserve bootstrap failure diagnostics
kerdofficial Oct 11, 2026
0ac3d4c
fix(runner): reconcile requests without cloud leases
kerdofficial Oct 11, 2026
5af0c49
chore(deps): bump @modelcontextprotocol/sdk from 1.30.0 to 1.32.1
dependabot[bot] Oct 14, 2026
7dfc506
feat(desktop): show runner lifecycle stages next to jobs
kerdofficial Oct 14, 2026
c91d8fc
feat(runner): release idle runners whose job no longer needs capacity
kerdofficial Oct 14, 2026
77010ac
docs(runner): explain idle runner drain
kerdofficial Oct 14, 2026
cebfb8b
fix(runner): decode runner busy state only for status reads
kerdofficial Oct 14, 2026
fa6b3a2
feat(jobs): download authorized GitHub job logs
kerdofficial Oct 14, 2026
a0d62c5
feat(jobs): read job logs through every surface
kerdofficial Oct 14, 2026
4e0ca4e
feat(desktop): read job logs beside jobs and runner activity
kerdofficial Oct 14, 2026
35ff99e
fix(cloud): expire runner requests that machines never claimed
kerdofficial Oct 14, 2026
4549860
docs(runner): explain unclaimed request expiry
kerdofficial Oct 14, 2026
bf51987
fix(desktop): back off job log polling while GitHub has no output
kerdofficial Oct 14, 2026
331545c
fix(security): redact credentials from logs and operation details
kerdofficial Oct 14, 2026
491a3fc
perf(server): redact operations once when they are written
kerdofficial Oct 14, 2026
8a4aacc
perf(jobs): page job logs with bounded byte ranges
kerdofficial Oct 14, 2026
46ee583
test(ci): validate Ubuntu container jobs and services
kerdofficial Oct 14, 2026
da7f537
fix(design): pin extracted fonts instead of the repackaged archive
kerdofficial Oct 14, 2026
17ada9b
feat(repositories): enforce binding access policies
kerdofficial Oct 14, 2026
6d0792d
feat(runner): verify assignments with a job-started hook
kerdofficial Oct 14, 2026
95a401f
fix(runner): stop the runner when a job assignment is rejected
kerdofficial Oct 14, 2026
17ef64c
feat(desktop): choose who can start runners and approve jobs
kerdofficial Oct 14, 2026
c459bd6
fix(desktop): approve jobs on the environment they asked for
kerdofficial Oct 14, 2026
bfa0c31
fix(runner): keep the assignment hook within its own runner process tree
kerdofficial Oct 14, 2026
66210f8
feat(runner): add opt-in Rosetta for Linux guests
kerdofficial Oct 14, 2026
5f09fa4
test(runner): allow real process trees time on loaded runners
kerdofficial Oct 14, 2026
b6853e5
docs(linux): explain optional x86_64 tools
kerdofficial Oct 14, 2026
b4349b8
feat(environment): configure guest network egress
kerdofficial Oct 14, 2026
461bd96
docs(environment): explain egress support by guest OS
kerdofficial Oct 14, 2026
65594e9
feat(desktop): configure x86_64 tools and guest network access
kerdofficial Oct 14, 2026
9a7a60c
Merge remote-tracking branch 'origin/dependabot/npm_and_yarn/modelcon…
kerdofficial Oct 14, 2026
761120d
Merge remote-tracking branch 'origin/fix/overnight-windows-setup-2026…
kerdofficial Oct 14, 2026
8ad4808
Merge remote-tracking branch 'origin/feat/shared-redaction' into HEAD
kerdofficial Oct 14, 2026
f170e48
Merge commit '8ad4808ee8754ef9bce46e9990764ea1fbdf9283' into HEAD
kerdofficial Oct 14, 2026
5e5bdca
Merge commit 'f170e485ed1f85b740c23f5f82158c795350d629' into HEAD
kerdofficial Oct 14, 2026
289a54e
Merge commit '5e5bdcac906e8e467865b8ff7028f4f4e33325b3' into HEAD
kerdofficial Oct 14, 2026
b3eb20b
Merge commit '289a54e30d9f30be1ec2d9e8e688bf345ce3dcd4' into HEAD
kerdofficial Oct 14, 2026
0cbb179
Merge integration into feat/repository-access-policy
kerdofficial Oct 14, 2026
1474878
Merge integration into feat/rosetta-x64
kerdofficial Oct 14, 2026
be8008c
Merge integration into feat/environment-egress
kerdofficial Oct 14, 2026
72a8331
Merge integration into vectis/environment-validation-20261010
kerdofficial Oct 14, 2026
27842c1
Merge integration into test/validate-container-services
kerdofficial Oct 14, 2026
8cf7f2f
Merge integration into feat/runner-stage-labels
kerdofficial Oct 14, 2026
84eab80
Merge integration into feat/job-logs
kerdofficial Oct 14, 2026
2d3a037
Merge main into feat/job-logs
kerdofficial Oct 14, 2026
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
189 changes: 189 additions & 0 deletions apps/cli/src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test, vi } from "vitest";
import { startService } from "../../server/src/http.js";
import type { JobLogRequest, JobLogs } from "../../../packages/protocol/src/jobs.js";

test("machine configure settles without --wait and status prints runner capacity", async () => {
const home = await mkdtemp(join(tmpdir(), "vectis-cli-capacity-"));
Expand Down Expand Up @@ -225,3 +226,191 @@ test.each([
}
},
);

test.each([false, true])("job logs read every retained page, with follow=%s", async (follow) => {
const home = await mkdtemp(join(tmpdir(), "vectis-cli-job-logs-"));
const server = await startService({ home });
const argv = process.argv;
const exitCode = process.exitCode;
const output = vi.spyOn(process.stdout, "write").mockReturnValue(true);
const read = vi
.spyOn(server.service, "jobLogs")
.mockResolvedValueOnce({
text: "first\n",
next: 6,
status: "in_progress",
complete: false,
truncated: false,
})
.mockResolvedValueOnce({
text: "second\n",
next: 13,
status: "completed",
complete: true,
truncated: false,
});
const interrupts = process.listenerCount("SIGINT");
try {
vi.resetModules();
process.argv = [
process.execPath,
"vectis",
"--home",
home,
"job",
"logs",
"4",
"--binding",
"binding1",
...(follow ? ["--follow"] : []),
];
await import("./main.js");
await vi.waitFor(() => expect(output).toHaveBeenCalledWith("second\n"), { timeout: 6000 });
expect(output).toHaveBeenCalledWith("first\n");
expect(read.mock.calls.map(([input]) => input)).toEqual([
{ bindingId: "binding1", jobId: 4 },
{ bindingId: "binding1", jobId: 4, after: 6 },
]);
expect(process.listenerCount("SIGINT")).toBe(interrupts);
expect(server.service.snapshot().operations).toHaveLength(0);
} finally {
process.argv = argv;
process.exitCode = exitCode;
output.mockRestore();
read.mockRestore();
await server.close();
await rm(home, { recursive: true, force: true });
}
});

test("job log follow stops on interrupt and releases its polling timer and listeners", async () => {
const home = await mkdtemp(join(tmpdir(), "vectis-cli-follow-"));
const server = await startService({ home });
const argv = process.argv;
const exitCode = process.exitCode;
const output = vi.spyOn(process.stdout, "write").mockReturnValue(true);
const read = vi.spyOn(server.service, "jobLogs").mockResolvedValue({
text: "waiting",
next: 7,
status: "in_progress",
complete: false,
truncated: false,
});
const interrupts = process.listenerCount("SIGINT");
try {
vi.resetModules();
process.argv = [
process.execPath,
"vectis",
"--home",
home,
"job",
"logs",
"4",
"--binding",
"binding1",
"--follow",
];
await import("./main.js");
await vi.waitFor(() => expect(output).toHaveBeenCalledWith("waiting"));
process.emit("SIGINT");
await vi.waitFor(() => expect(process.listenerCount("SIGINT")).toBe(interrupts));
expect(read).toHaveBeenCalledOnce();
expect(process.exitCode).toBe(exitCode);
} finally {
process.argv = argv;
process.exitCode = exitCode;
output.mockRestore();
read.mockRestore();
await server.close();
await rm(home, { recursive: true, force: true });
}
});

test.each([false, true])(
"job log follow backs off empty chunks and resets on text, json=%s",
async (json) => {
const home = await mkdtemp(join(tmpdir(), "vectis-cli-backoff-"));
const argv = process.argv;
const exitCode = process.exitCode;
const output = vi.spyOn(process.stdout, "write").mockReturnValue(true);
const empty: JobLogs = {
text: "",
next: 0,
status: "in_progress",
complete: false,
truncated: false,
};
const read = vi
.fn<(input: JobLogRequest, signal?: AbortSignal) => Promise<JobLogs>>()
.mockResolvedValue(empty);
for (let index = 0; index < 6; index++) read.mockResolvedValueOnce(empty);
read.mockResolvedValueOnce({ ...empty, text: "new output", next: 10 });
read.mockResolvedValueOnce({ ...empty, next: 10 });
read.mockResolvedValueOnce({ ...empty, next: 10, status: "completed", complete: true });
const interrupts = process.listenerCount("SIGINT");
const terminations = process.listenerCount("SIGTERM");
vi.resetModules();
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
vi.doMock("../../../packages/client/src/local.js", () => ({
localClient: async (selectedHome: string) => {
expect(selectedHome).toBe(home);
return { jobLogs: read };
},
}));
vi.doMock("node:timers/promises", () => ({
setTimeout: (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds)),
}));
try {
process.argv = [
process.execPath,
"vectis",
"--home",
home,
"job",
"logs",
"4",
"--binding",
"binding1",
"--follow",
...(json ? ["--json"] : []),
];
await import("./main.js");
await vi.advanceTimersByTimeAsync(0);
expect(read).toHaveBeenCalledOnce();
const delays = [2000, 4000, 8000, 16000, 30000, 30000, 2000, 2000];
for (const [index, delay] of delays.entries()) {
await vi.advanceTimersByTimeAsync(delay - 1);
expect(read).toHaveBeenCalledTimes(index + 1);
await vi.advanceTimersByTimeAsync(1);
expect(read).toHaveBeenCalledTimes(index + 2);
}
expect(read.mock.calls.map(([input]) => input)).toEqual([
{ bindingId: "binding1", jobId: 4 },
...Array.from({ length: 6 }, () => ({ bindingId: "binding1", jobId: 4, after: 0 })),
...Array.from({ length: 2 }, () => ({ bindingId: "binding1", jobId: 4, after: 10 })),
]);
expect(output).toHaveBeenCalledWith(
json ? expect.stringContaining('"text":"new output"') : "new output",
);
expect(process.listenerCount("SIGINT")).toBe(interrupts);
expect(process.listenerCount("SIGTERM")).toBe(terminations);
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(60000);
expect(read).toHaveBeenCalledTimes(9);
expect(process.exitCode).toBe(exitCode);
} finally {
if (process.listenerCount("SIGINT") > interrupts) process.emit("SIGINT");
vi.clearAllTimers();
vi.useRealTimers();
vi.doUnmock("node:timers/promises");
vi.doUnmock("../../../packages/client/src/local.js");
vi.resetModules();
process.argv = argv;
process.exitCode = exitCode;
output.mockRestore();
await rm(home, { recursive: true, force: true });
}
},
);
45 changes: 45 additions & 0 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#!/usr/bin/env node
import { setTimeout as delay } from "node:timers/promises";
import { JobLogRequest } from "../../../packages/protocol/src/jobs.js";
import { parseArgs } from "node:util";
import { readFile, mkdir, open } from "node:fs/promises";
import { spawn } from "node:child_process";
Expand Down Expand Up @@ -74,6 +76,8 @@ async function main() {
"memory-mib": { type: "string" },
"storage-path": { type: "string" },
map: { type: "string", multiple: true },
binding: { type: "string" },
follow: { type: "boolean" },
lines: { type: "string" },
limit: { type: "string" },
kind: { type: "string" },
Expand Down Expand Up @@ -150,6 +154,7 @@ Repositories and jobs
repository access <binding-id> --actors any|<id,id,...> --approval automatic|required
Actor IDs are numeric GitHub user IDs: gh api users/<login> --jq .id
job list <binding-id> Read recent GitHub job states for a connected repository
job logs <job-id> --binding <id> [--follow] Read the retained log; follow backs off from 2 to 30 seconds when empty
job scan <binding-id> Discover missing jobs through the GitHub API
job refresh <binding-id> <job-id> Recover one job state directly from GitHub

Expand Down Expand Up @@ -465,6 +470,46 @@ Documentation: https://vectis.kerd.dev/docs
output(await api.jobs(id));
return;
}
if (command === "job" && subcommand === "logs") {
const input = { bindingId: values.binding, jobId: Number(id) };
if (!Schema.is(JobLogRequest)(input))
throw new VectisError(
"invalid_argument",
"Use job logs <job-id> --binding <binding-id> with a positive whole job ID.",
);
const abort = new AbortController();
const stop = () => abort.abort();
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
let after: number | undefined;
let pollDelay = 2000;
try {
while (!abort.signal.aborted) {
const log = await api.jobLogs(
{ ...input, ...(after === undefined ? {} : { after }) },
abort.signal,
);
if (values.json) output(log);
else process.stdout.write(log.text);
if (log.truncated)
process.stderr.write("Earlier job log content was skipped (4 MiB retained window).\n");
after = log.next;
if (log.complete) break;
if (!log.text && !values.follow) break;
if (values.follow) {
if (log.text) pollDelay = 2000;
await delay(pollDelay, undefined, { signal: abort.signal });
if (!log.text) pollDelay = Math.min(pollDelay * 2, 30000);
}
}
} catch (error) {
if (!abort.signal.aborted) throw error;
} finally {
process.removeListener("SIGINT", stop);
process.removeListener("SIGTERM", stop);
}
return;
}
if (command === "repository" && subcommand === "list") {
output(await api.repositories());
return;
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 @@ -11,6 +11,7 @@ export type DesktopAction =
| "repositories"
| "github.accounts"
| "jobs"
| "job.logs"
| "command"
| "command.settle"
| "service.install"
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { JobLogRequest } from "../../../packages/protocol/src/jobs.js";
import { existsSync } from "node:fs";
import { rm, writeFile } from "node:fs/promises";
import electronUpdater from "electron-updater";
Expand Down Expand Up @@ -197,6 +198,13 @@ else {
data = await (await client()).jobs(bindingId);
break;
}
case "job.logs": {
const request = Schema.decodeUnknownSync(JobLogRequest, {
onExcessProperty: "error",
})(input);
data = await (await client()).jobLogs(request);
break;
}
case "github.accounts":
data = await (await client()).githubAccounts();
break;
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/renderer/components/activity-row.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useState } from "react";
import { ExternalLinkIcon } from "lucide-react";
import type { Activity, Operation } from "../../../../../packages/protocol/src/index.js";
import { GitHubIcon } from "@/components/github-icon";
import { JobLog } from "@/components/job-log";
import { Details, ExpandableRow, Mono } from "@/components/layout";
import { OsTile } from "@/components/os-icon";
import { JobStatus, OperationStatus, RunnerStage, StatusText } from "@/components/status";
Expand Down Expand Up @@ -39,6 +41,7 @@ export function ActivityRow({
? steps.at(-1)
: steps.find((step) => step.id === activity.rootOperationId);
const job = activityJob(activity);
const [logOpen, setLogOpen] = useState(false);
// A runner that did its part is not news once GitHub reports the job; the job outcome is.
const runnerQuiet = activity.status === "succeeded" && job?.status !== undefined;
const running = activity.status === "accepted" || activity.status === "running";
Expand Down Expand Up @@ -117,6 +120,23 @@ export function ActivityRow({
</pre>
</div>
)}
{job && activity.bindingId && (
<div className="flex flex-col gap-3">
<div>
<Button
variant="secondary"
size="sm"
aria-expanded={logOpen}
onClick={() => setLogOpen((value) => !value)}
>
{logOpen ? "Hide job log" : "View job log"}
</Button>
</div>
{logOpen && (
<JobLog bindingId={activity.bindingId} jobId={job.jobId} htmlUrl={job.htmlUrl} />
)}
</div>
)}
{job?.htmlUrl && (
<div>
<Button
Expand Down
Loading
Loading