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
8 changes: 6 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,15 @@ WORKDIR /app

RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
&& rm -rf /var/lib/apt/lists/* \
&& install -d -o bun -g bun -m 0700 /var/lib/chopin

# The atomic Planner's per-document directories and workflow journal live under
# $XDG_STATE_HOME/chopin; mount a volume at /var/lib/chopin to keep them.
ENV NODE_ENV=production \
PORT=8787 \
SERVER_HOST=0.0.0.0
SERVER_HOST=0.0.0.0 \
XDG_STATE_HOME=/var/lib/chopin

COPY --from=production-dependencies --chown=bun:bun /app/node_modules ./node_modules
COPY --from=production-dependencies --chown=bun:bun /app/apps/server/node_modules ./apps/server/node_modules
Expand Down
20 changes: 18 additions & 2 deletions apps/server/src/harness/atomic/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ import {
SessionManager,
SettingsManager,
} from "@bastani/atomic";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { fullPlanner, workflowRuns } from "./full";

import type {
Expand Down Expand Up @@ -154,6 +154,13 @@ export type AtomicSettings = {
* sessions never load them.
*/
extensions?: readonly string[];
/**
* The SQLite file the workflows of Planner sessions are journaled in, in place
* of Atomic's default beside its agent directory. Atomic reads the journal
* location only from `ATOMIC_WORKFLOW_JOURNAL_PATH` and the on-disk settings,
* so the adapter sets that variable until it shuts down.
*/
journal?: string;
};

export class ToolBoundaryError extends Error {
Expand Down Expand Up @@ -465,6 +472,8 @@ export function createAtomicAdapter(
}
let runtime: Promise<ModelRuntime> | undefined;
let open = new Set<HarnessV1Session>();
let journal = process.env.ATOMIC_WORKFLOW_JOURNAL_PATH;
if (settings.journal) process.env.ATOMIC_WORKFLOW_JOURNAL_PATH = settings.journal;

return {
specificationVersion: "harness-v1",
Expand Down Expand Up @@ -615,6 +624,10 @@ export function createAtomicAdapter(
let cwd = full?.cwd ?? directory!;
let agentDir = full ? getAgentDir() : directory!;
if (full) settingsManager = await fullSettings(agentDir, cwd);
// The journal holds workflow inputs and outputs; Atomic would create its directory world-readable.
if (full && settings.journal) {
await mkdir(dirname(settings.journal), { recursive: true, mode: 0o700 });
}
sessionManager ??= SessionManager.inMemory(cwd);
let loader = new DefaultResourceLoader({
cwd,
Expand Down Expand Up @@ -825,6 +838,9 @@ export function createAtomicAdapter(

async shutdown() {
await Promise.allSettled([...open].map(session => session.doDestroy()));
if (!settings.journal) return;
if (journal === undefined) delete process.env.ATOMIC_WORKFLOW_JOURNAL_PATH;
else process.env.ATOMIC_WORKFLOW_JOURNAL_PATH = journal;
},
};
}
10 changes: 8 additions & 2 deletions apps/server/src/harness/atomic/full.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,11 @@ async function run(
let previous = process.env.ATOMIC_CODING_AGENT_DIR;
let received: QuestionParams[] = [];
let operatorPackageDir = join(root, "operator-package");
let journal = join(root, "state", "workflows.sqlite");
let harness = createAtomicAdapter({
auth: "ai-gateway",
model: "stub/model",
journal,
extensions: operatorPackage || askingWorkflow ? [operatorPackageDir] : undefined,
providers: {
stub: {
Expand Down Expand Up @@ -316,6 +318,8 @@ export default workflow({
if (!worker) {
let files = {
operator: await Bun.file(join(agentDir, "settings.json")).exists(),
journal: await Bun.file(journal).exists(),
defaultJournal: await Bun.file(join(root, "workflows", "journal.sqlite")).exists(),
project: projectPackage
? await Bun.file(join(cwd, ".atomic", "settings.json")).text()
: undefined,
Expand Down Expand Up @@ -443,7 +447,7 @@ test("a checkout's project settings add its packages without writing either sett
expect(plain.requests[0]!.system).not.toContain("PROJECT-SKILL-MARKER");
});

/** The first workflow tool call starts Atomic's durable backend, which falls back slowly without Postgres. */
/** The first workflow tool call loads Atomic's workflow runtime and opens its journal. */
const WORKFLOW_TOOL_TIMEOUT_MS = 30_000;

test(
Expand Down Expand Up @@ -490,7 +494,7 @@ test(
delete process.env.NODE_ENV;
try {
let seen: string[] = [];
await run(true, "launch", {
let result = await run(true, "launch", {
host: room.input,
askingWorkflow: true,
afterTurn: async () => {
Expand Down Expand Up @@ -527,6 +531,8 @@ test(
expect(stage.toolNames).toContain("write");
expect(stage.toolNames).not.toContain(ATOMIC_RESULT_TOOL_NAME);
expect(stage.system).not.toContain("CHOPIN-INSTRUCTIONS-MARKER");
// The run is journaled where Chopin chose, not in Atomic's default beside its agent directory.
expect(result.files).toMatchObject({ journal: true, defaultJournal: false });
} finally {
if (environment !== undefined) process.env.NODE_ENV = environment;
await room.close();
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/harness/atomic/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ export function stateDirectory(): string {
return join(process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "chopin");
}

/**
* Where Planner sessions journal their workflow runs. A run resumes only with
* the journal and working directory it started with, so the journal lives
* beside the channel directories rather than in Atomic's own home.
*/
export function workflowJournal(): string {
return join(stateDirectory(), "atomic", "workflows.sqlite");
}

/**
* A stable directory per channel, private to the server's user. It lives in a
* per-user directory rather than the shared tmp, so a predictable name is safe;
Expand Down
29 changes: 29 additions & 0 deletions apps/server/src/harness/harnesses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,35 @@ it("refuses Atomic's host-login auto mode on a non-loopback bind but accepts ai-
.toBe("atomic");
});

it("journals Atomic workflow runs in Chopin's state directory unless the operator names a journal", async () => {
let previous = {
state: process.env.XDG_STATE_HOME,
journal: process.env.ATOMIC_WORKFLOW_JOURNAL_PATH,
};
let state = join(tmpdir(), `chopin-state-${crypto.randomUUID()}`);
let restore = (name: string, value: string | undefined) =>
value === undefined ? delete process.env[name] : process.env[name] = value;
let journal = (): string | undefined => process.env.ATOMIC_WORKFLOW_JOURNAL_PATH;
try {
process.env.XDG_STATE_HOME = state;
delete process.env.ATOMIC_WORKFLOW_JOURNAL_PATH;
harnessFor({ harness: "atomic", harnessAuth: "ai-gateway", host: "0.0.0.0" });
if (process.platform === "linux") {
expect(journal()).toBe(join(state, "chopin", "atomic", "workflows.sqlite"));
}
await shutdownHarnesses();
expect(journal()).toBeUndefined();

process.env.ATOMIC_WORKFLOW_JOURNAL_PATH = "/srv/atomic/runs.sqlite";
harnessFor({ harness: "atomic", harnessAuth: "ai-gateway", host: "0.0.0.0" });
expect(journal()).toBe("/srv/atomic/runs.sqlite");
} finally {
await shutdownHarnesses();
restore("XDG_STATE_HOME", previous.state);
restore("ATOMIC_WORKFLOW_JOURNAL_PATH", previous.journal);
}
});

it("accepts the remote harness on any bind without HARNESS_AUTH, and refuses one", () => {
for (let host of ["0.0.0.0", "::", "192.168.1.2", "127.0.0.1"]) {
expect(harnessFor({ harness: "remote", host }).harnessId).toBe("remote");
Expand Down
5 changes: 3 additions & 2 deletions apps/server/src/harness/harnesses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
import { isAbsolute } from "node:path";

import { ATOMIC_AUTH_MODES, createAtomicAdapter } from "./atomic/adapter";
import { forgetWorkspaces } from "./atomic/workspace";
import { forgetWorkspaces, workflowJournal } from "./atomic/workspace";
import { createCopilotSdk } from "./copilot-sdk/adapter";
import { createRemoteAdapter } from "./remote/adapter";

Expand All @@ -18,10 +18,11 @@ function createAtomicHarness(
settings: { auth?: string; extensions?: readonly string[] },
): HarnessV1 & { shutdown(): Promise<void> } {
// Atomic takes neither a GitHub credential nor a credit limit; only `auth` (validated by
// `harnessFor` below) and the extensions reach the real adapter.
// `harnessFor` below), the extensions, and the workflow journal reach the real adapter.
return createAtomicAdapter({
auth: settings.auth as AtomicAuthMode,
extensions: settings.extensions,
journal: process.env.ATOMIC_WORKFLOW_JOURNAL_PATH || workflowJournal(),
});
}

Expand Down
Loading
Loading