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
2 changes: 1 addition & 1 deletion .changeset/atomic-project-state-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
"@sapiom/harness": minor
---

Migrate persisted project maps atomically to immutable version histories and role-neutral proposal attribution, with storage for shared build plans. Map and brief quotas, malformed aggregates and unsupported storage schemas now report terminal manual-intervention recovery through MCP; operation history is explicitly bounded before writes.
Store project maps atomically with immutable version histories and role-neutral proposal attribution, alongside shared build plans. Map and brief quotas, malformed aggregates and unsupported storage schemas now report terminal manual-intervention recovery through MCP; operation history is explicitly bounded before writes. This storage contract does not preserve format-1 maps: startup resets those maps under the legacy-reset policy.

**Breaking:** `ProposalActor` and proposal-history payloads now contain only trusted `userId` and `sessionId` attribution. Consumers must stop reading or constructing the removed `role` and `assignment` fields and use `sessionId` for attribution. Those fields never represented write or implementation authority.
2 changes: 1 addition & 1 deletion .changeset/bootstrap-coordinator.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@sapiom/harness": patch
---

Internal groundwork for automatic Agent Map bootstrap, including recovery, FIFO delivery, and shutdown handling. Recovery events describe committed state. No user-facing behavior changes in this release.
Support automatic Agent Map bootstrap with recovery, FIFO delivery, and shutdown handling. Recovery events describe committed state.
2 changes: 1 addition & 1 deletion .changeset/bootstrap-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@sapiom/harness": patch
---

Internal storage groundwork for automatic Agent Map bootstrap. Clean up temporary state after failed writes and ignore unrelated files when reading durable project intents. No user-facing behavior changes in this release.
Add durable project intents for automatic Agent Map bootstrap. Clean up temporary state after failed writes and ignore unrelated files when reading project intents.
7 changes: 7 additions & 0 deletions .changeset/desktop-managed-installs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@sapiom/harness-desktop": patch
---

Install and verify isolated coding-provider versions using the bundled runtime,
with bounded installer processes and cancellation. Keep Electron's runtime
startup flag out of commands launched by managed providers.
7 changes: 7 additions & 0 deletions .changeset/managed-agent-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@sapiom/harness": minor
---

Add optional interpreter arguments and environment to coding-provider adapters
and export `createCodexAdapter`. Preserve managed launch configuration across
new sessions, resume, and private structured inference for both providers.
2 changes: 1 addition & 1 deletion .changeset/project-session-shortcut.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
"@sapiom/harness-desktop": patch
---

The project-row `+` now starts a coding-agent session at that project root. Previously it created an agent. Sapiom agent creation remains owned by Plan Agents.
The project-row `+` now starts an ordinary coding-agent session at that project root. Sessions can create agents and work on the shared Agent Map; Plan Agents is an ordinary session without exclusive creation authority.
7 changes: 7 additions & 0 deletions .changeset/studio-terminal-teardown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@sapiom/harness": patch
---

Prevent terminal mount cleanup from disposing the renderer before xterm's
queued viewport initialization runs. Ignore callbacks from closed terminal
connections and preserve workspace preferences across reloads in the Studio demo.
4 changes: 3 additions & 1 deletion .github/workflows/harness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ jobs:
# invokes them — they require real agent binaries and credentials not in CI.
playwright-mock:
runs-on: ubuntu-latest
timeout-minutes: 15
# The expanded mock suite has over 560 cases, followed by canvas checks.
# Keep the existing per-test deadlines; allow both suites to finish.
timeout-minutes: 20
# Single Node version is enough — the mock tier tests browser behaviour,
# not Node version compat; that's covered by the matrix job in test.yml.
steps:
Expand Down
15 changes: 15 additions & 0 deletions packages/harness-desktop/src/main/agent-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { readFileSync } from "node:fs";
import * as path from "node:path";
import { promisify } from "node:util";
import { CLAUDE_INSTALL_COMMAND } from "@sapiom/harness";
import { runUpdateCommand } from "./agent-update-process.js";
import {
SAPIOM_CLI_PACKAGE,
SAPIOM_MCP_PACKAGE,
Expand Down Expand Up @@ -125,6 +126,20 @@ export function installClaudeCode(
return installNpmGlobal(packageSpecFromInstallCommand(CLAUDE_INSTALL_COMMAND), onLine);
}

/** Install an exact version into an isolated prefix with a bounded deadline. */
export async function installAgentVersion(
packageSpec: string,
prefix: string,
onLine: (line: string) => void,
): Promise<boolean> {
const result = await runUpdateCommand(process.execPath, [
resolveNpmCli(), "install", "--global", packageSpec, "--prefix", prefix,
"--no-audit", "--no-fund", "--loglevel=info", "--fetch-retries=0", "--fetch-timeout=15000",
], { env: npmInstallEnv(process.env), timeoutMs: 90_000, onLine });
if (!result.ok) onLine(result.detail);
return result.ok;
}

/**
* Install the `sapiom` CLI into the same per-user prefix, for the same reason and
* by the same mechanism as the agent: the app hands the coding agent
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { describe, expect, it, vi } from "vitest";

const { spawn } = vi.hoisted(() => ({ spawn: vi.fn() }));
vi.mock("node:child_process", async (original) => ({
...await original<typeof import("node:child_process")>(), spawn,
}));
import { runUpdateCommand } from "./agent-update-process.js";

describe.skipIf(process.platform === "win32")("POSIX update group ownership", () => {
it("never signals the old group after its supervisor has exited", async () => {
const child = Object.assign(new EventEmitter(), {
pid: 12345, stdin: new PassThrough(), stdout: new PassThrough(),
stderr: new PassThrough(), kill: vi.fn(),
});
spawn.mockReturnValueOnce(child);
const signal = vi.spyOn(process, "kill").mockReturnValue(true);
try {
const result = runUpdateCommand("installer", [], { env: {}, timeoutMs: 10 });
child.emit("exit", 0);
expect((await result).detail).toContain("Timed out");
expect(signal).not.toHaveBeenCalled();
expect(child.kill).not.toHaveBeenCalled();
} finally { signal.mockRestore(); }
});
});
144 changes: 144 additions & 0 deletions packages/harness-desktop/src/main/agent-update-process.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { runUpdateCommand, windowsUpdateTree } from "./agent-update-process.js";

describe("Windows update process identity", () => {
const identity = { pid: 10, startedAt: 100, spawnedAt: 110 };
const root = { pid: 10, parentPid: 1, createdAt: 105 };
const child = { pid: 20, parentPid: 10, createdAt: 120 };
const grandchild = { pid: 30, parentPid: 20, createdAt: 130 };

it("stops descendants before the original parent", () => {
expect(windowsUpdateTree([root, child, grandchild], identity, 200)).toEqual(
[grandchild, child, root],
);
});

it("includes children spawned by a live parent while the snapshot was being collected", () => {
const lateChild = { pid: 40, parentPid: 10, createdAt: 220 };
expect(windowsUpdateTree([root, lateChild], identity, 200)).toEqual([
lateChild,
root,
]);
});

it("finds orphaned descendants but excludes children born after npm exited", () => {
const unrelated = { pid: 40, parentPid: 10, createdAt: 180 };
expect(
windowsUpdateTree(
[child, grandchild, unrelated],
{ ...identity, exitedAt: 150 },
200,
),
).toEqual([grandchild, child]);
});

it("never kills or traverses an unrelated process that reused the root PID", () => {
const replacement = { ...root, createdAt: 160 };
const unrelated = { pid: 40, parentPid: 10, createdAt: 180 };
expect(windowsUpdateTree([replacement, unrelated], identity, 200)).toEqual(
[],
);
expect(
windowsUpdateTree([root, child], { ...identity, exitedAt: 150 }, 200),
).toEqual([]);
});
});

describe("bounded update processes", () => {
it("preserves argument boundaries, environment and both output streams", async () => {
const result = await runUpdateCommand(process.execPath, ["-e", `
console.log(JSON.stringify({args:process.argv.slice(1), flag:process.env.ELECTRON_RUN_AS_NODE??null, retained:process.env.STUDIO_UPDATE_TEST}));
console.error('stderr retained');
`, "space and $literal"], {
env: { PATH: process.env.PATH, STUDIO_UPDATE_TEST: "retained" }, timeoutMs: 2_000,
});
expect(result.ok).toBe(true);
expect(JSON.parse(result.stdout)).toEqual({args:["space and $literal"],flag:null,retained:"retained"});
expect(result.detail).toContain("stderr retained");
});

it("reports missing executables and failed commands without hanging startup", async () => {
const opts = { env: process.env, timeoutMs: 1_000 };
const missing = await runUpdateCommand(
"studio-deliberately-missing-executable",
[],
opts,
);
const failed = await runUpdateCommand(
process.execPath,
["-e", "process.exit(1)"],
opts,
);
expect(missing.ok).toBe(false);
expect(failed.ok).toBe(false);
});

it.each([false, true])(
"kills a timed-out process tree, including an already-exited parent (%s)",
async (parentExits) => {
const root = await mkdtemp(join(tmpdir(), "studio-update-timeout-"));
const marker = join(root, "child-pid");
try {
const childCode = "setInterval(() => {}, 1000)";
const code = `const {spawn}=require('node:child_process'); const c=spawn(process.execPath,['-e',${JSON.stringify(childCode)}],{stdio:['ignore',1,2]}); require('node:fs').writeFileSync(${JSON.stringify(marker)},String(c.pid)); ${parentExits ? "process.exit(0)" : "setInterval(()=>{},1000)"};`;
const result = await runUpdateCommand(process.execPath, ["-e", code], {
env: process.env,
timeoutMs: 700,
});
expect(result.ok).toBe(false);
expect(result.detail).toContain("Timed out");
const pid = Number(await readFile(marker, "utf8"));
// Linux can retain a terminated process as a zombie.
await vi.waitFor(
async () => {
if (process.platform === "linux") {
const status = await readFile(
`/proc/${pid}/status`,
"utf8",
).catch(() => "State:\tZ");
expect(status).toMatch(/State:\s+Z/);
} else {
expect(() => process.kill(pid, 0)).toThrow();
}
},
{ timeout: 1_000, interval: 20 },
);
} finally {
await rm(root, { recursive: true, force: true });
}
},
);

it("cancels running updates and prevents new installers when Studio quits during setup", async () => {
vi.resetModules();
const commands = await import("./agent-update-process.js");
let ready!: () => void;
const started = new Promise<void>((resolve) => {
ready = resolve;
});
const command = commands.runUpdateCommand(
process.execPath,
["-e", "console.log('ready');setInterval(()=>{},1000)"],
{
env: process.env,
timeoutMs: 5_000,
onLine: () => ready(),
},
);
await started;
await commands.stopAgentUpdateCommands();
expect((await command).detail).toBe("Studio is quitting");
const rejected = await commands.runUpdateCommand(
process.execPath,
["-e", "process.exit(0)"],
{
env: process.env,
timeoutMs: 1_000,
},
);
expect(rejected.ok).toBe(false);
});
});
Loading
Loading