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
11 changes: 11 additions & 0 deletions apps/server/src/auth/RpcAuthorization.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
PROMACHOS_HOME_WS_METHODS,
clientRpcRequiredScopes,
AuthEnvironmentMaintainScope,
AuthDiagnosticsReadScope,
AuthFilesystemReadScope,
Expand Down Expand Up @@ -31,6 +33,15 @@ import {
import * as RpcAuthorization from "./RpcAuthorization.ts";

describe("RPC authorization scopes", () => {
it("requires operate scope for Promachos home creation on both client and server", () => {
expect(requiredScopeForRpcMethod(PROMACHOS_HOME_WS_METHODS.create)).toBe(
AuthOrchestrationOperateScope,
);
expect(
clientRpcRequiredScopes(PROMACHOS_HOME_WS_METHODS.create, { path: "~/promachos" }),
).toEqual([AuthOrchestrationOperateScope]);
});

it("declares exactly one scope for every RPC in the server group", () => {
expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys()));
});
Expand Down
8 changes: 7 additions & 1 deletion apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { ORCHESTRATION_V2_WS_METHODS, WS_METHODS, type WsRpcGroup } from "@t3tools/contracts";
import {
ORCHESTRATION_V2_WS_METHODS,
PROMACHOS_HOME_WS_METHODS,
WS_METHODS,
type WsRpcGroup,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as References from "effect/References";
Expand All @@ -16,6 +21,7 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];
* prefix. Adding an RPC to `WsRpcGroup` without a label is a type error.
*/
const RPC_AGGREGATES = {
[PROMACHOS_HOME_WS_METHODS.create]: "promachos",
[ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: "orchestrationV2",
[ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: "orchestration",
[ORCHESTRATION_V2_WS_METHODS.getTurnItem]: "orchestration",
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { registerPromachosLaunchTests } from "../promachos/PromachosLaunch.tests.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as SqlClient from "effect/sql/SqlClient";
import * as Scheduler from "../scheduling/Scheduler.ts";
Expand Down Expand Up @@ -236,6 +237,7 @@ function makeHarness(options: HarnessOptions = {}) {
);
return {
layer: Layer.mergeAll(
layerRegistry,
layerLaunch,
layerThreadManagement,
layerTitleRegeneration,
Expand Down Expand Up @@ -2539,3 +2541,5 @@ it.effect.each([false, true])(
);
}),
);

registerPromachosLaunchTests({ makeHarness, launchInput, modelSelection });
128 changes: 128 additions & 0 deletions apps/server/src/promachos/PromachosHome.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// @effect-diagnostics nodeBuiltinImport:off -- real git is the external boundary under test.
import * as NodeChildProcess from "node:child_process";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import { ProjectId, type Project } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as ProjectService from "../project/ProjectService.ts";
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import * as ServerConfig from "../config.ts";
import * as PromachosHome from "./PromachosHome.ts";

const base = Layer.mergeAll(
NodeServices.layer,
WorkspacePaths.layer.pipe(
Layer.provide(NodeServices.layer),
Layer.provide(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-promachos-home-" }).pipe(
Layer.provide(NodeServices.layer),
),
),
),
VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer), Layer.provide(NodeServices.layer)),
);
const temporary = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
return yield* fs.makeTempDirectoryScoped({ prefix: "175-promachos-" });
});
function projects(existing?: ProjectId) {
const created: string[] = [];
let id = existing;
const layer = Layer.mock(ProjectService.ProjectService)({
getByWorkspaceRoot: () => Effect.succeed(id ? Option.some({ id } as Project) : Option.none()),
create: (input) =>
Effect.sync(() => {
created.push(input.workspaceRoot);
id = input.projectId;
return { id } as Project;
}),
});
return { created, layer };
}
const create = (path: string, layer: Layer.Layer<ProjectService.ProjectService>) =>
PromachosHome.PromachosHome.pipe(
Effect.flatMap((home) => home.create({ path })),
Effect.provide(PromachosHome.layer.pipe(Layer.provide(layer))),
);

it.layer(base, { excludeTestServices: true })("Promachos home", (it) => {
it.effect("creates a git home and registers it through ProjectService once", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = path.join(yield* temporary, "promachos");
const service = projects();
const first = yield* create(root, service.layer);
const second = yield* create(root, service.layer);
expect(second).toEqual(first);
expect(service.created).toEqual([root]);
expect(
NodeChildProcess.execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
cwd: root,
encoding: "utf8",
}).trim(),
).toBe("true");
expect(yield* fs.readFileString(path.join(root, "AGENTS.md"))).toContain("## Who you are");
expect(yield* fs.readFileString(path.join(root, "CLAUDE.md"))).toBe("@AGENTS.md\n");
}),
);
it.effect("keeps an existing persona, repository and registered project", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* temporary;
NodeChildProcess.execFileSync("git", ["init", "--quiet"], { cwd: root });
yield* fs.writeFileString(path.join(root, "AGENTS.md"), "# My persona\n");
yield* fs.writeFileString(path.join(root, "CLAUDE.md"), "my instructions\n");
const service = projects(ProjectId.make("existing"));
expect((yield* create(root, service.layer)).projectId).toBe("existing");
expect(service.created).toEqual([]);
expect(yield* fs.readFileString(path.join(root, "AGENTS.md"))).toBe("# My persona\n");
expect(yield* fs.readFileString(path.join(root, "CLAUDE.md"))).toBe("my instructions\n");
}),
);
it.effect("does not nest a git repository inside an existing checkout", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const parent = yield* temporary;
NodeChildProcess.execFileSync("git", ["init", "--quiet"], { cwd: parent });
const root = path.join(parent, "home");
yield* create(root, projects().layer);
expect(yield* fs.exists(path.join(root, ".git"))).toBe(false);
expect(yield* fs.exists(path.join(root, "AGENTS.md"))).toBe(true);
}),
);
it.effect("reuses a project that another client registers during creation", () =>
Effect.gen(function* () {
const root = yield* temporary;
let added = false;
const id = ProjectId.make("concurrent-home");
const layer = Layer.mock(ProjectService.ProjectService)({
getByWorkspaceRoot: () =>
Effect.sync(() => (added ? Option.some({ id } as Project) : Option.none())),
create: () =>
Effect.sync(() => {
added = true;
}).pipe(
Effect.andThen(
Effect.fail(
new ProjectService.ProjectConflictError({
projectId: ProjectId.make("ours"),
workspaceRoot: root,
conflictingProjectId: id,
}),
),
),
),
});
expect((yield* create(root, layer)).projectId).toBe(id);
}),
);
});
143 changes: 143 additions & 0 deletions apps/server/src/promachos/PromachosHome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* Creates a starter Promachos home (toolboxmd/chromeria#139) on this server's machine, so it
* works the same for local and remote environments.
*
* Every step keeps what is already there: an existing folder, git repository (including a
* parent repository or a worktree), `AGENTS.md`, `CLAUDE.md` or project is reused. A failed
* attempt therefore leaves nothing that blocks a retry.
*/
import {
CommandId,
ProjectId,
PromachosHomeError,
type PromachosHomeCreateInput,
type PromachosHomeCreateResult,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as ProjectService from "../project/ProjectService.ts";
import { randomUuidV4 } from "../orchestration-v2/RandomUuid.ts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";

import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";
import { WorkspacePaths } from "../workspace/WorkspacePaths.ts";

const STARTER_AGENTS_MD = `# Promachos home

This folder is the Promachos home. Every Promachos conversation runs here and reads this file.

## Who you are

<!-- Write the Promachos persona here: his name, how he speaks, and how he works with you. -->
`;

const STARTER_CLAUDE_MD = "@AGENTS.md\n";

export class PromachosHome extends Context.Service<
PromachosHome,
{
readonly create: (
input: PromachosHomeCreateInput,
) => Effect.Effect<PromachosHomeCreateResult, PromachosHomeError>;
}
>()("t3/promachos/PromachosHome") {}

const make = Effect.gen(function* () {
const projects = yield* ProjectService.ProjectService;
const workspacePaths = yield* WorkspacePaths;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const vcs = yield* VcsDriverRegistry;
const create = Effect.fn("PromachosHome.create")(function* (input: PromachosHomeCreateInput) {
const causeMessage = (cause: unknown) =>
cause instanceof Error ? cause.message : String(cause);

const fail = (detail: string) => Effect.fail(new PromachosHomeError({ detail }));

const workspaceRoot = yield* workspacePaths
.normalizeWorkspaceRoot(input.path, { createIfMissing: true })
.pipe(
Effect.catch((error) =>
fail(
error._tag === "WorkspaceRootCreateFailedError"
? `Could not create ${error.normalizedWorkspaceRoot}: ${causeMessage(error.cause)}`
: error.message,
),
),
);

// Git runs before any file is written, so a missing git leaves only an empty folder.
yield* Effect.gen(function* () {
const git = yield* vcs.get("git");
if (!(yield* git.isInsideWorkTree(workspaceRoot))) {
yield* git.initRepository({ cwd: workspaceRoot });
}
}).pipe(
Effect.catch((error) =>
fail(
error._tag === "VcsProcessSpawnError"
? "Git could not run on this machine. Install git and try again."
: `Could not create a git repository in ${workspaceRoot}: ${error.message}`,
),
),
);

for (const [name, contents] of [
["AGENTS.md", STARTER_AGENTS_MD],
["CLAUDE.md", STARTER_CLAUDE_MD],
] as const) {
const file = path.join(workspaceRoot, name);
// The file is written in full inside a private folder, then hard-linked into place. The
// link never replaces an existing file, and cleanup removes only the private folder, so a
// failure leaves nothing behind that blocks a retry.
yield* Effect.scoped(
Effect.gen(function* () {
const staging = yield* fileSystem.makeTempDirectoryScoped({
directory: workspaceRoot,
prefix: ".promachos-home-",
});
const staged = path.join(staging, name);
yield* fileSystem.writeFileString(staged, contents);
yield* fileSystem.link(staged, file).pipe(
Effect.catchIf(
(error) => error.reason._tag === "AlreadyExists",
() => Effect.void,
),
);
}),
).pipe(Effect.catch((error) => fail(`Could not write ${file}: ${causeMessage(error)}`)));
}

const find = projects.getByWorkspaceRoot(workspaceRoot).pipe(Effect.map(Option.getOrUndefined));
const findOrCreate = Effect.gen(function* () {
const existing = yield* find;
if (existing !== undefined) return existing.id;
const id = yield* randomUuidV4;
return (yield* projects.create({
commandId: CommandId.make(`promachos-home:${id}`),
projectId: ProjectId.make(id),
workspaceRoot,
title: path.basename(workspaceRoot),
})).id;
});
const projectId = yield* findOrCreate.pipe(
Effect.catch((error) =>
find.pipe(
Effect.flatMap((existing) =>
existing === undefined ? Effect.fail(error) : Effect.succeed(existing.id),
),
),
),
Effect.catch((error) =>
fail(`Could not add ${workspaceRoot} as a project: ${error.message}`),
),
);
return { projectId, workspaceRoot } satisfies PromachosHomeCreateResult;
});

return PromachosHome.of({ create });
});
export const layer = Layer.effect(PromachosHome, make);
Loading
Loading