Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
feat(harness): resolve projects into spaces; split git into repo/work…
…tree domains)
  • Loading branch information
sanchitrk committed Sep 14, 2026
commit 46cb4d833bab1e2366fbc5bfdf53477782553bdb
12 changes: 10 additions & 2 deletions extras/codework-sandbox-vercel/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ const status = (remote: Remote): SandboxDriver.Observed => ({
providerStatus: remote.status,
});

const filesystem = (remote: Remote): SandboxFileSystem.Interface =>
const filesystem = (remote: Remote, execTimeout: number | undefined): SandboxFileSystem.Interface =>
SandboxFileSystem.fromProvider({
readFile: (path) => remote.fs.readFile(path, "utf8"),
readFileBuffer: async (path) => new Uint8Array(await remote.fs.readFile(path)),
Expand Down Expand Up @@ -85,6 +85,14 @@ const filesystem = (remote: Remote): SandboxFileSystem.Interface =>
await remote.fs.mkdir(path, options?.recursive === undefined ? {} : { recursive: options.recursive });
},
rm: (path, options) => remote.fs.rm(path, options),
realpath: async (path) => {
for (const script of SandboxFileSystem.realpathScripts) {
const result = await (await command(remote, execTimeout, ["sh", "-c", script, "_", path])).wait();
if (result.exitCode === 0) return (await result.stdout()).trimEnd();
}
// same shape `fs.stat` rejects with, so `isNotFoundError` recognises it
throw Object.assign(new Error(`ENOENT: no such file or directory, realpath '${path}'`), { code: "ENOENT" });
},
});

const command = (
Expand Down Expand Up @@ -211,7 +219,7 @@ export const make = (options: Options = {}) => {
),
(remote) =>
Layer.merge(
Layer.succeed(SandboxIO.FileSystem, filesystem(remote)),
Layer.succeed(SandboxIO.FileSystem, filesystem(remote, input.runtimeConfig.execTimeout)),
Layer.succeed(SandboxIO.Shell, shell(remote, input.runtimeConfig.execTimeout)),
),
),
Expand Down
5 changes: 4 additions & 1 deletion packages/harness/src/db/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ export { SqlClient, SqlSchema } from "effect/unstable/sql";
// client holds a single connection for the lifetime of the layer, so the settings
// apply to every query and a `:memory:` database stays intact across
// transactions — important for serverless deployments and transient sessions
// where no writable disk is available. (WAL is enabled by the client itself.)
// where no writable disk is available. WAL is stated explicitly even though the
// client defaults to it: readers must never block the resolve/refresh writer.
// A `:memory:` database ignores it and reports `memory`, which is fine.
const setup = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`PRAGMA journal_mode = WAL`;
yield* sql`PRAGMA synchronous = NORMAL`;
yield* sql`PRAGMA busy_timeout = 5000`;
yield* sql`PRAGMA cache_size = -64000`;
Expand Down
60 changes: 39 additions & 21 deletions packages/harness/src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ export const migrations = {
yield* sql`CREATE UNIQUE INDEX event_aggregate_id_seq_idx ON event (aggregate_id, seq)`;
yield* sql`CREATE INDEX event_aggregate_id_type_seq_idx ON event (aggregate_id, type, seq)`;

// A durable filesystem namespace. Created before project_directory and
// session because both reference it. Reference counts are deliberately
// A durable filesystem namespace. Created before space because it
// references it. Reference counts are deliberately
// absent: they live in control-plane memory, since a persisted count cannot
// tell whether the process that took it is still running.
//
Expand Down Expand Up @@ -78,54 +78,71 @@ export const migrations = {
WHERE provider_resource_id IS NOT NULL AND status != 'removed'
`;

// A logical codebase, env-independent. Archived rather than deleted once
// it has no active space anywhere, so session history stays reachable.
yield* sql`
CREATE TABLE project (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('active', 'archived')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`;

// Nullable, NULL = the host. RESTRICT, not CASCADE: destroying
// infrastructure tombstones the sandbox row rather than deleting it, so
// history keeps a valid reference. The FK is skipped on NULL, so a host
// directory can be written before any namespace is registered.
// One directory in one env. `id` is Hash.fast([env|'local', location]) —
// no project component, so re-pointing a space to another project keeps
// every session key intact. `env` NULL = host (same convention as
// sandbox_instance_id everywhere else); the FK is skipped on NULL.
// RESTRICT on both FKs: a space with sessions is archived, never deleted,
// and a destroyed sandbox is tombstoned, not removed.
yield* sql`
CREATE TABLE project_directory (
CREATE TABLE space (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES project(id) ON UPDATE CASCADE ON DELETE CASCADE,
directory TEXT NOT NULL,
type TEXT NOT NULL,
sandbox_instance_id TEXT
project_id TEXT NOT NULL REFERENCES project(id) ON UPDATE CASCADE ON DELETE RESTRICT,
location TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('primary', 'linked', 'copy', 'plain')),
env TEXT
REFERENCES sandbox_instance(id) ON UPDATE CASCADE ON DELETE RESTRICT
CHECK (sandbox_instance_id IS NULL OR sandbox_instance_id <> 'local'),
CHECK (env IS NULL OR env <> 'local'),
status TEXT NOT NULL CHECK (status IN ('active', 'archived')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`;

// SQLite treats NULLs as distinct in a unique index, so (project, NULL, '/repo')
// would insert twice. Coalescing to the reserved id makes the
// index read as what it means.
// One place, one row. SQLite treats NULLs as distinct in a unique index,
// so (NULL, '/repo') would insert twice; coalescing to the reserved id
// makes the index read as what it means.
yield* sql`
CREATE UNIQUE INDEX project_directory_project_directory_idx
ON project_directory (project_id, COALESCE(sandbox_instance_id, 'local'), directory)
CREATE UNIQUE INDEX space_location_idx
ON space (COALESCE(env, 'local'), location)
`;

// Exactly one primary per (project, env). Partial: the other kinds are
// unbounded. Status is deliberately not part of the predicate — an
// archived primary must be demoted before another can be promoted.
yield* sql`
CREATE UNIQUE INDEX space_primary_idx
ON space (project_id, COALESCE(env, 'local')) WHERE kind = 'primary'
`;

yield* sql`CREATE INDEX space_project_idx ON space (project_id)`;

// A session belongs to a space; project and env are derived through it,
// never denormalised here. `directory` is RELATIVE to space.location
// ('' = root) so a space can move without touching its sessions.
// RESTRICT: sessions are history, a referenced space cannot go away.
yield* sql`
CREATE TABLE session (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES project(id) ON UPDATE CASCADE ON DELETE CASCADE,
space_id TEXT NOT NULL REFERENCES space(id) ON UPDATE CASCADE ON DELETE RESTRICT,
parent_id TEXT REFERENCES session(id) ON UPDATE CASCADE ON DELETE SET NULL,
slug TEXT NOT NULL,
directory TEXT NOT NULL,
title TEXT NOT NULL,
tag TEXT,
metadata TEXT,
sandbox_instance_id TEXT
REFERENCES sandbox_instance(id) ON UPDATE CASCADE ON DELETE RESTRICT
CHECK (sandbox_instance_id IS NULL OR sandbox_instance_id <> 'local'),
cost REAL NOT NULL DEFAULT 0,
tokens_input INTEGER NOT NULL DEFAULT 0,
tokens_output INTEGER NOT NULL DEFAULT 0,
Expand All @@ -136,6 +153,7 @@ export const migrations = {
)
`;

yield* sql`CREATE INDEX session_space_idx ON session (space_id)`;
yield* sql`CREATE UNIQUE INDEX session_slug_idx ON session (slug)`;
yield* sql`CREATE INDEX session_tag_idx ON session (tag)`;

Expand Down
27 changes: 16 additions & 11 deletions packages/harness/src/db/schema.sql.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Schema } from "effect";
import { Model } from "effect/unstable/schema";
import { EventSchema } from "../event/schema.ts";
import { ProjectSchema } from "../project/schema.ts";
import { SandboxInstance } from "../sandbox/instance.ts";
import { AbsolutePath, NonNegativeInt } from "../schema.ts";
import { AbsolutePath, NonNegativeInt, RelativePath } from "../schema.ts";
import { Prompt } from "../session/prompt/schema.ts";
import { SessionSchema } from "../session/schema.ts";
import { SpaceSchema } from "../space/schema.ts";

// Column names derive from field names via the client's camelToSnake

Expand Down Expand Up @@ -73,6 +75,7 @@ export class EventRow extends Model.Class<EventRow>("EventRow")({
export class ProjectRow extends Model.Class<ProjectRow>("ProjectRow")({
id: Schema.String,
name: Schema.String,
status: ProjectSchema.Status,
...Timestamps,
}) {}

Expand Down Expand Up @@ -103,29 +106,31 @@ export class SandboxInstanceRow extends Model.Class<SandboxInstanceRow>("Sandbox
...Timestamps,
}) {}

export class ProjectDirectoryRow extends Model.Class<ProjectDirectoryRow>("ProjectDirectoryRow")({
// One directory in one env that belongs to a project. `id = hash(env, location)`.
export class SpaceRow extends Model.Class<SpaceRow>("SpaceRow")({
id: Schema.String,
projectId: Schema.String,
directory: AbsolutePath,
type: Schema.Literals(["main", "root", "gitworktree"]),
// Nullable: NULL is the host. Service-level code sees a plain ID through
location: AbsolutePath,
kind: SpaceSchema.Kind,
// Nullable: NULL is the host (same convention as sandboxInstanceId
// elsewhere). Service-level code sees a plain ID through
// SandboxInstance.toColumn/fromColumn and never branches on the host.
sandboxInstanceId: Model.FieldOption(SandboxInstance.ID),
env: Model.FieldOption(SandboxInstance.ID),
status: SpaceSchema.Status,
...Timestamps,
}) {}

export class SessionRow extends Model.Class<SessionRow>("SessionRow")({
id: SessionSchema.IDFromDb,
projectId: Schema.String,
// Project and env are derived through the space, never denormalised here.
spaceId: Schema.String,
parentId: Model.FieldOption(SessionSchema.IDFromDb),
slug: Schema.String,
directory: AbsolutePath,
// Relative to `space.location`; `""` is the space root.
directory: RelativePath,
title: Schema.String,
tag: Model.FieldOption(Schema.String),
metadata: Model.FieldOption(Model.JsonFromString(Metadata)),
// Nullable: NULL is the host (§4), so a session can be written before any
// namespace is registered — the foreign key is skipped on NULL.
sandboxInstanceId: Model.FieldOption(SandboxInstance.ID),
// Durable leaf cursor: read anchor (path walks leaf→root) and append anchor
// (new entries attach here). Moved inside every append/branch transaction.
leafEntryId: Model.FieldOption(Schema.String),
Expand Down
42 changes: 20 additions & 22 deletions packages/harness/src/effect/session.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Model } from "@codeworksh/aikit";
import { DateTime, Effect, Option, Stream } from "effect";
import { SqlClient } from "effect/unstable/sql";
import { Effect, Option, Stream } from "effect";
import * as Control from "../control.ts";
import * as Event from "../event/event.ts";
import type { EventSchema } from "../event/schema.ts";
import { Location } from "../location/location.ts";
import * as SandboxController from "../sandbox/control.ts";
import { SandboxInstance as SandboxInstanceSchema } from "../sandbox/instance.ts";
import { AbsolutePath } from "../schema.ts";
Expand All @@ -14,6 +14,7 @@ import * as SessionRuntime from "../session/runtime.ts";
import { SessionSchema } from "../session/schema.ts";
import { Session as SessionStore } from "../session/session.ts";
import type { State } from "../state/state.ts";
import { posix } from "../util/posix.ts";
import type { Info as SandboxInfo } from "./sandbox.ts";

export interface ModelConfig {
Expand Down Expand Up @@ -103,17 +104,20 @@ const makeHandle = Effect.fn("Session.makeHandle")(function* (id: SessionSchema.

const info = Effect.gen(function* () {
const found = yield* sessions.get(id);
if (Option.isNone(found)) return yield* new SessionStore.SessionNotFoundError({ sessionId: id });
const space = yield* sessions.space(id);
if (Option.isNone(found) || Option.isNone(space)) {
return yield* new SessionStore.SessionNotFoundError({ sessionId: id });
}
const row = found.value;
const sandboxId = SandboxInstanceSchema.fromField(row.sandboxInstanceId);
// Env and absolute cwd are the space's; the row only knows its offset within it.
const sandbox =
sandboxId === SandboxInstanceSchema.ID.local
space.value.env === SandboxInstanceSchema.ID.local
? undefined
: Option.getOrUndefined(yield* sandboxes.get(sandboxId));
: Option.getOrUndefined(yield* sandboxes.get(space.value.env));
return {
id,
title: row.title,
directory: AbsolutePath.make(row.directory),
directory: AbsolutePath.make(posix.join(space.value.location, row.directory)),
...(sandbox === undefined ? {} : { sandbox }),
};
}).pipe(Effect.withSpan("Session.info"));
Expand All @@ -135,31 +139,25 @@ const makeHandle = Effect.fn("Session.makeHandle")(function* (id: SessionSchema.
} satisfies Handle;
});

const ensureLocalProject = Effect.fn("Session.ensureLocalProject")(function* () {
const sql = yield* SqlClient.SqlClient;
const now = DateTime.toEpochMillis(yield* DateTime.now);
yield* sql`
INSERT INTO project (id, name, created_at, updated_at)
VALUES ('local', 'local', ${now}, ${now})
ON CONFLICT(id) DO NOTHING
`.pipe(Effect.orDie);
});

export const create = Effect.fn("Session.create")(function* (input: CreateInput = {}) {
const sessions = yield* SessionStore.Service;
const runtime = yield* SessionRuntime.Service;
const sandboxes = yield* SandboxController.Controller;
const id = SessionSchema.ID.create();
const sandboxId = input.sandbox?.id ?? SandboxInstanceSchema.ID.local;
const directory = yield* sandboxes.resolveCwd(sandboxId, input.directory);
yield* ensureLocalProject();
// Mount, then resolve the cwd into its space. The mount is
// what makes the directory mean anything, so resolution happens inside it.
const location = yield* sandboxes.withMount(
sandboxId,
Effect.provide(Location.Service.use(Effect.succeed), Location.layerMounted()),
input.directory === undefined ? undefined : { cwd: input.directory },
);
yield* sessions.create({
id,
projectId: "local",
spaceId: location.space.id,
directory: location.directory,
slug: id,
title: input.title ?? "Session",
directory: AbsolutePath.make(directory),
sandboxInstanceId: sandboxId,
});
yield* runtime.set(id, runtimeBindings(input));
return yield* makeHandle(id);
Expand Down
Loading