Skip to content

Commit 30b7a85

Browse files
authored
Merge pull request #30 from codeworksh/harness-project-nd-spaces
feat(harness): projects and spaces
2 parents 3a46860 + 5ba7478 commit 30b7a85

66 files changed

Lines changed: 3144 additions & 2187 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎extras/codework-sandbox-vercel/src/index.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ const status = (remote: Remote): SandboxDriver.Observed => ({
5555
providerStatus: remote.status,
5656
});
5757

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

9098
const command = (
@@ -211,7 +219,7 @@ export const make = (options: Options = {}) => {
211219
),
212220
(remote) =>
213221
Layer.merge(
214-
Layer.succeed(SandboxIO.FileSystem, filesystem(remote)),
222+
Layer.succeed(SandboxIO.FileSystem, filesystem(remote, input.runtimeConfig.execTimeout)),
215223
Layer.succeed(SandboxIO.Shell, shell(remote, input.runtimeConfig.execTimeout)),
216224
),
217225
),

‎packages/harness/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@
7474
"test": "vp test",
7575
"test:sandbox:package": "node test/scripts/codework-sandbox-vercel-smoke.mjs",
7676
"test:sandbox:package:e2e": "node test/scripts/codework-sandbox-vercel-smoke.mjs --e2e",
77-
"test:sandbox:e2e": "CODEWORK_SANDBOX_E2E_REQUIRED=1 vp test --no-file-parallelism test/sandbox.vercel.e2e.test.ts test/sandbox.daytona.e2e.test.ts test/sandbox.remote.driver.e2e.test.ts test/tools.registry.remote.e2e.test.ts",
77+
"test:sandbox:e2e": "CODEWORK_SANDBOX_E2E_REQUIRED=1 vp test --no-file-parallelism test/sandbox.vercel.e2e.test.ts test/sandbox.daytona.e2e.test.ts test/sandbox.remote.driver.e2e.test.ts test/tools.registry.remote.e2e.test.ts test/session.relink.e2e.test.ts",
7878
"typecheck": "tsc --noEmit"
7979
},
8080
"dependencies": {

‎packages/harness/src/db/db.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ export { SqlClient, SqlSchema } from "effect/unstable/sql";
1212
// client holds a single connection for the lifetime of the layer, so the settings
1313
// apply to every query and a `:memory:` database stays intact across
1414
// transactions — important for serverless deployments and transient sessions
15-
// where no writable disk is available. (WAL is enabled by the client itself.)
15+
// where no writable disk is available. WAL is stated explicitly even though the
16+
// client defaults to it: readers must never block the resolve/refresh writer.
17+
// A `:memory:` database ignores it and reports `memory`, which is fine.
1618
const setup = Effect.gen(function* () {
1719
const sql = yield* SqlClient.SqlClient;
20+
yield* sql`PRAGMA journal_mode = WAL`;
1821
yield* sql`PRAGMA synchronous = NORMAL`;
1922
yield* sql`PRAGMA busy_timeout = 5000`;
2023
yield* sql`PRAGMA cache_size = -64000`;

‎packages/harness/src/db/migrations.ts‎

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ export const migrations = {
3838
yield* sql`CREATE UNIQUE INDEX event_aggregate_id_seq_idx ON event (aggregate_id, seq)`;
3939
yield* sql`CREATE INDEX event_aggregate_id_type_seq_idx ON event (aggregate_id, type, seq)`;
4040

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

81+
// A logical codebase, env-independent. Archived rather than deleted once
82+
// it has no active space anywhere, so session history stays reachable.
8183
yield* sql`
8284
CREATE TABLE project (
8385
id TEXT PRIMARY KEY,
8486
name TEXT NOT NULL,
87+
status TEXT NOT NULL CHECK (status IN ('active', 'archived')),
8588
created_at INTEGER NOT NULL,
8689
updated_at INTEGER NOT NULL
8790
)
8891
`;
8992

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

108-
// SQLite treats NULLs as distinct in a unique index, so (project, NULL, '/repo')
109-
// would insert twice. Coalescing to the reserved id makes the
110-
// index read as what it means.
114+
// One place, one row. SQLite treats NULLs as distinct in a unique index,
115+
// so (NULL, '/repo') would insert twice; coalescing to the reserved id
116+
// makes the index read as what it means.
111117
yield* sql`
112-
CREATE UNIQUE INDEX project_directory_project_directory_idx
113-
ON project_directory (project_id, COALESCE(sandbox_instance_id, 'local'), directory)
118+
CREATE UNIQUE INDEX space_location_idx
119+
ON space (COALESCE(env, 'local'), location)
114120
`;
115121

122+
// Exactly one primary per (project, env). Partial: the other kinds are
123+
// unbounded. Status is deliberately not part of the predicate — an
124+
// archived primary must be demoted before another can be promoted.
125+
yield* sql`
126+
CREATE UNIQUE INDEX space_primary_idx
127+
ON space (project_id, COALESCE(env, 'local')) WHERE kind = 'primary'
128+
`;
129+
130+
yield* sql`CREATE INDEX space_project_idx ON space (project_id)`;
131+
132+
// A session belongs to a space; project and env are derived through it,
133+
// never denormalised here. `directory` is the ABSOLUTE realpath of the
134+
// session cwd, always equal to or under space.location (which is immutable).
135+
// RESTRICT: sessions are history, a referenced space cannot go away.
116136
yield* sql`
117137
CREATE TABLE session (
118138
id TEXT PRIMARY KEY,
119-
project_id TEXT NOT NULL REFERENCES project(id) ON UPDATE CASCADE ON DELETE CASCADE,
139+
space_id TEXT NOT NULL REFERENCES space(id) ON UPDATE CASCADE ON DELETE RESTRICT,
120140
parent_id TEXT REFERENCES session(id) ON UPDATE CASCADE ON DELETE SET NULL,
121141
slug TEXT NOT NULL,
122142
directory TEXT NOT NULL,
123143
title TEXT NOT NULL,
124144
tag TEXT,
125145
metadata TEXT,
126-
sandbox_instance_id TEXT
127-
REFERENCES sandbox_instance(id) ON UPDATE CASCADE ON DELETE RESTRICT
128-
CHECK (sandbox_instance_id IS NULL OR sandbox_instance_id <> 'local'),
129146
cost REAL NOT NULL DEFAULT 0,
130147
tokens_input INTEGER NOT NULL DEFAULT 0,
131148
tokens_output INTEGER NOT NULL DEFAULT 0,
@@ -136,6 +153,7 @@ export const migrations = {
136153
)
137154
`;
138155

156+
yield* sql`CREATE INDEX session_space_idx ON session (space_id)`;
139157
yield* sql`CREATE UNIQUE INDEX session_slug_idx ON session (slug)`;
140158
yield* sql`CREATE INDEX session_tag_idx ON session (tag)`;
141159

‎packages/harness/src/db/schema.sql.ts‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { Schema } from "effect";
22
import { Model } from "effect/unstable/schema";
33
import { EventSchema } from "../event/schema.ts";
4+
import { ProjectSchema } from "../project/schema.ts";
45
import { SandboxInstance } from "../sandbox/instance.ts";
56
import { AbsolutePath, NonNegativeInt } from "../schema.ts";
67
import { Prompt } from "../session/prompt/schema.ts";
78
import { SessionSchema } from "../session/schema.ts";
9+
import { SpaceSchema } from "../space/schema.ts";
810

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

@@ -73,6 +75,7 @@ export class EventRow extends Model.Class<EventRow>("EventRow")({
7375
export class ProjectRow extends Model.Class<ProjectRow>("ProjectRow")({
7476
id: Schema.String,
7577
name: Schema.String,
78+
status: ProjectSchema.Status,
7679
...Timestamps,
7780
}) {}
7881

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

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

117123
export class SessionRow extends Model.Class<SessionRow>("SessionRow")({
118124
id: SessionSchema.IDFromDb,
119-
projectId: Schema.String,
125+
// Project and env are derived through the space, never denormalised here.
126+
spaceId: Schema.String,
120127
parentId: Model.FieldOption(SessionSchema.IDFromDb),
121128
slug: Schema.String,
129+
// Absolute realpath of the session cwd; always equal to or under `space.location`.
122130
directory: AbsolutePath,
123131
title: Schema.String,
124132
tag: Model.FieldOption(Schema.String),
125133
metadata: Model.FieldOption(Model.JsonFromString(Metadata)),
126-
// Nullable: NULL is the host (§4), so a session can be written before any
127-
// namespace is registered — the foreign key is skipped on NULL.
128-
sandboxInstanceId: Model.FieldOption(SandboxInstance.ID),
129134
// Durable leaf cursor: read anchor (path walks leaf→root) and append anchor
130135
// (new entries attach here). Moved inside every append/branch transaction.
131136
leafEntryId: Model.FieldOption(Schema.String),

‎packages/harness/src/effect/session.ts‎

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import type { Model } from "@codeworksh/aikit";
2-
import { DateTime, Effect, Option, Stream } from "effect";
3-
import { SqlClient } from "effect/unstable/sql";
2+
import { Effect, Option, Stream } from "effect";
43
import * as Control from "../control.ts";
54
import * as Event from "../event/event.ts";
65
import type { EventSchema } from "../event/schema.ts";
6+
import { Location } from "../location/location.ts";
77
import * as SandboxController from "../sandbox/control.ts";
88
import { SandboxInstance as SandboxInstanceSchema } from "../sandbox/instance.ts";
9+
import { SandboxIO } from "../sandbox/io.ts";
910
import { AbsolutePath } from "../schema.ts";
1011
import { SessionMessageSchema } from "../session/message/schema.ts";
1112
import type { Delivery } from "../session/prompt/schema.ts";
@@ -48,6 +49,12 @@ export interface AttachInput extends RuntimeInput {
4849
readonly sessionId: SessionSchema.ID;
4950
}
5051

52+
export interface RelinkInput {
53+
readonly sessionId: SessionSchema.ID;
54+
readonly sandbox?: SandboxInfo;
55+
readonly directory?: string;
56+
}
57+
5158
export type PromptInput =
5259
| string
5360
| {
@@ -105,15 +112,18 @@ const makeHandle = Effect.fn("Session.makeHandle")(function* (id: SessionSchema.
105112
const found = yield* sessions.get(id);
106113
if (Option.isNone(found)) return yield* new SessionStore.SessionNotFoundError({ sessionId: id });
107114
const row = found.value;
108-
const sandboxId = SandboxInstanceSchema.fromField(row.sandboxInstanceId);
115+
// The env is the space's. A missing space or a destroyed env both read as
116+
// "no sandbox": the handle stays readable (title, directory); running it
117+
// is the mount's call, which refuses a removed instance.
118+
const space = Option.getOrUndefined(yield* sessions.space(id));
109119
const sandbox =
110-
sandboxId === SandboxInstanceSchema.ID.local
120+
space === undefined || space.env === SandboxInstanceSchema.ID.local
111121
? undefined
112-
: Option.getOrUndefined(yield* sandboxes.get(sandboxId));
122+
: Option.getOrUndefined(yield* sandboxes.get(space.env));
113123
return {
114124
id,
115125
title: row.title,
116-
directory: AbsolutePath.make(row.directory),
126+
directory: row.directory,
117127
...(sandbox === undefined ? {} : { sandbox }),
118128
};
119129
}).pipe(Effect.withSpan("Session.info"));
@@ -135,36 +145,63 @@ const makeHandle = Effect.fn("Session.makeHandle")(function* (id: SessionSchema.
135145
} satisfies Handle;
136146
});
137147

138-
const ensureLocalProject = Effect.fn("Session.ensureLocalProject")(function* () {
139-
const sql = yield* SqlClient.SqlClient;
140-
const now = DateTime.toEpochMillis(yield* DateTime.now);
141-
yield* sql`
142-
INSERT INTO project (id, name, created_at, updated_at)
143-
VALUES ('local', 'local', ${now}, ${now})
144-
ON CONFLICT(id) DO NOTHING
145-
`.pipe(Effect.orDie);
146-
});
147-
148148
export const create = Effect.fn("Session.create")(function* (input: CreateInput = {}) {
149149
const sessions = yield* SessionStore.Service;
150150
const runtime = yield* SessionRuntime.Service;
151151
const sandboxes = yield* SandboxController.Controller;
152152
const id = SessionSchema.ID.create();
153153
const sandboxId = input.sandbox?.id ?? SandboxInstanceSchema.ID.local;
154-
const directory = yield* sandboxes.resolveCwd(sandboxId, input.directory);
155-
yield* ensureLocalProject();
154+
// Mount, then resolve the cwd into its space. The mount is
155+
// what makes the directory mean anything, so resolution happens inside it.
156+
const location = yield* sandboxes.withMount(
157+
sandboxId,
158+
Effect.provide(Location.Service.use(Effect.succeed), Location.layerMounted()),
159+
input.directory === undefined ? undefined : { cwd: input.directory },
160+
);
156161
yield* sessions.create({
157162
id,
158-
projectId: "local",
163+
spaceId: location.space.id,
164+
directory: location.directory,
159165
slug: id,
160166
title: input.title ?? "Session",
161-
directory: AbsolutePath.make(directory),
162-
sandboxInstanceId: sandboxId,
163167
});
164168
yield* runtime.set(id, runtimeBindings(input));
165169
return yield* makeHandle(id);
166170
});
167171

172+
/**
173+
* Move a session to wherever a checkout of the same project now lives — the
174+
* remote env died and the repo was cloned locally, say. `directory` is
175+
* resolved into its space exactly like `create`; the session keeps its
176+
* position under the new root when that absolute path exists there, else it
177+
* lands on the resolved directory. The transcript is untouched.
178+
*/
179+
export const relink = Effect.fn("Session.relink")(function* (input: RelinkInput) {
180+
const sessions = yield* SessionStore.Service;
181+
const sandboxes = yield* SandboxController.Controller;
182+
const sandboxId = input.sandbox?.id ?? SandboxInstanceSchema.ID.local;
183+
return yield* sandboxes.withMount(
184+
sandboxId,
185+
Effect.gen(function* () {
186+
const location = yield* Location.Service.use(Effect.succeed).pipe(Effect.provide(Location.layerMounted()));
187+
const session = yield* sessions.get(input.sessionId);
188+
const current = yield* sessions.space(input.sessionId);
189+
const rebased =
190+
Option.isSome(session) && Option.isSome(current)
191+
? SessionStore.rebaseDirectory(current.value.location, location.space.location, session.value.directory)
192+
: undefined;
193+
const fs = yield* SandboxIO.FileSystem;
194+
const directory =
195+
rebased !== undefined && (yield* fs.exists(rebased).pipe(Effect.orElseSucceed(() => false)))
196+
? rebased
197+
: location.directory;
198+
yield* sessions.relink({ sessionId: input.sessionId, spaceId: location.space.id, directory });
199+
return yield* makeHandle(input.sessionId);
200+
}),
201+
input.directory === undefined ? undefined : { cwd: input.directory },
202+
);
203+
});
204+
168205
export const get = Effect.fn("Session.get")(function* (sessionId: SessionSchema.ID) {
169206
const sessions = yield* SessionStore.Service;
170207
const found = yield* sessions.get(sessionId);

0 commit comments

Comments
 (0)