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): relink space
  • Loading branch information
sanchitrk committed Sep 14, 2026
commit 4b49c92e9604b6ba2124ef8fcd012d7d54714a52
2 changes: 1 addition & 1 deletion packages/harness/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
"test": "vp test",
"test:sandbox:package": "node test/scripts/codework-sandbox-vercel-smoke.mjs",
"test:sandbox:package:e2e": "node test/scripts/codework-sandbox-vercel-smoke.mjs --e2e",
"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: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",
"typecheck": "tsc --noEmit"
},
"dependencies": {
Expand Down
40 changes: 40 additions & 0 deletions packages/harness/src/effect/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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 { SandboxIO } from "../sandbox/io.ts";
import { AbsolutePath } from "../schema.ts";
import { SessionMessageSchema } from "../session/message/schema.ts";
import type { Delivery } from "../session/prompt/schema.ts";
Expand Down Expand Up @@ -48,6 +49,12 @@ export interface AttachInput extends RuntimeInput {
readonly sessionId: SessionSchema.ID;
}

export interface RelinkInput {
readonly sessionId: SessionSchema.ID;
readonly sandbox?: SandboxInfo;
readonly directory?: string;
}

export type PromptInput =
| string
| {
Expand Down Expand Up @@ -162,6 +169,39 @@ export const create = Effect.fn("Session.create")(function* (input: CreateInput
return yield* makeHandle(id);
});

/**
* Move a session to wherever a checkout of the same project now lives — the
* remote env died and the repo was cloned locally, say. `directory` is
* resolved into its space exactly like `create`; the session keeps its
* position under the new root when that absolute path exists there, else it
* lands on the resolved directory. The transcript is untouched.
*/
export const relink = Effect.fn("Session.relink")(function* (input: RelinkInput) {
const sessions = yield* SessionStore.Service;
const sandboxes = yield* SandboxController.Controller;
const sandboxId = input.sandbox?.id ?? SandboxInstanceSchema.ID.local;
return yield* sandboxes.withMount(
sandboxId,
Effect.gen(function* () {
const location = yield* Location.Service.use(Effect.succeed).pipe(Effect.provide(Location.layerMounted()));
const session = yield* sessions.get(input.sessionId);
const current = yield* sessions.space(input.sessionId);
const rebased =
Option.isSome(session) && Option.isSome(current)
? SessionStore.rebaseDirectory(current.value.location, location.space.location, session.value.directory)
: undefined;
const fs = yield* SandboxIO.FileSystem;
const directory =
rebased !== undefined && (yield* fs.exists(rebased).pipe(Effect.orElseSucceed(() => false)))
? rebased
: location.directory;
yield* sessions.relink({ sessionId: input.sessionId, spaceId: location.space.id, directory });
return yield* makeHandle(input.sessionId);
}),
input.directory === undefined ? undefined : { cwd: input.directory },
);
});

export const get = Effect.fn("Session.get")(function* (sessionId: SessionSchema.ID) {
const sessions = yield* SessionStore.Service;
const found = yield* sessions.get(sessionId);
Expand Down
86 changes: 85 additions & 1 deletion packages/harness/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ import {
import { Event } from "../event/event.ts";
import { EventList } from "../event/list.ts";
import { ProjectSchema } from "../project/schema.ts";
import type { AbsolutePath } from "../schema.ts";
import { AbsolutePath } from "../schema.ts";
import { SpaceSchema } from "../space/schema.ts";
import { Space } from "../space/space.ts";
import { posix } from "../util/posix.ts";
import { SessionSchema } from "./schema.ts";

export {
Expand Down Expand Up @@ -63,6 +64,32 @@ export class InvalidEntryDataError extends Schema.TaggedError<InvalidEntryDataEr
reason: Schema.String,
}) {}

// Relink rejections: the target is not a live space, the session would cross
// projects, or an explicit directory would break the containment invariant.
export class RelinkError extends Schema.TaggedError<RelinkError>()("RelinkError", {
sessionId: Schema.String,
spaceId: Schema.String,
reason: Schema.Literals(["space-not-found", "space-archived", "project-mismatch", "directory-outside-space"]),
}) {}
export type RelinkReason = RelinkError["reason"];

// I6: a session's directory is its space's location or sits underneath it.
const directoryWithin = (location: AbsolutePath, directory: AbsolutePath) =>
directory === location || directory.startsWith(`${location}/`);

/**
* Re-root a session directory onto another space of the same project: the
* position relative to the old root is kept, so `dir = /old/app/pkg/x` under
* `from = /old/app` lands on `/new/app/pkg/x` under `to = /new/app`. A
* directory not under the old root — a broken invariant — lands on the new
* root instead.
*/
export const rebaseDirectory = (from: AbsolutePath, to: AbsolutePath, directory: AbsolutePath): AbsolutePath => {
if (!directoryWithin(from, directory)) return to;
const relative = posix.relative(from, directory);
return AbsolutePath.make(relative === "" ? to : posix.join(to, relative));
};

export interface CreateSession {
readonly id?: SessionSchema.ID;
/** The space (one directory in one env) this session attaches to; must exist. */
Expand Down Expand Up @@ -150,6 +177,20 @@ export interface ForkInput {
readonly tag?: string; // default: source tag
}

// The env move: point a session at another space of the same project (remote
// env died, repo cloned locally, worktree consolidated). Transcript entries
// are keyed by session id and are untouched.
export interface RelinkInput {
readonly sessionId: SessionSchema.ID;
/** Target space; must be active and belong to the session's project. */
readonly spaceId: SpaceSchema.ID;
/**
* Landing directory; must equal or sit under the target space's location.
* Default: `rebaseDirectory` of the current directory onto the new root.
*/
readonly directory?: AbsolutePath;
}

// Entry row + its part rows, zipped. Message decode happens above this layer.
export interface HydratedEntry {
readonly entry: SessionEntryRow;
Expand Down Expand Up @@ -188,6 +229,12 @@ export interface Interface {
readonly fork: (
input: ForkInput,
) => Effect.Effect<SessionRow, SessionNotFoundError | EntryNotFoundError | InvalidEntryDataError>;
/**
* Move the session to another space of the same project. Without an explicit
* `directory` the cwd keeps its position relative to the old root; either way
* the result satisfies I6 (directory at or under the space's location).
*/
readonly relink: (input: RelinkInput) => Effect.Effect<SessionRow, SessionNotFoundError | RelinkError>;
/** Move the leaf cursor; the next append forks a sibling branch. */
readonly branch: (input: {
sessionId: SessionSchema.ID;
Expand Down Expand Up @@ -232,6 +279,12 @@ export const layer = Layer.effect(
`,
});

const findSpaceById = SqlSchema.findOneOption({
Request: Schema.String,
Result: SpaceRow,
execute: (id) => sql`SELECT * FROM space WHERE id = ${id}`,
});

const selectByProject = SqlSchema.findAll({
Request: Schema.String,
Result: SessionRow,
Expand Down Expand Up @@ -1021,6 +1074,36 @@ export const layer = Layer.effect(
}
});

const relink = Effect.fn("Session.relink")(function* (input: RelinkInput) {
const reject = (reason: RelinkReason) =>
new RelinkError({ sessionId: input.sessionId, spaceId: input.spaceId, reason });
const session = yield* findSession(input.sessionId).pipe(Effect.orDie);
if (Option.isNone(session)) return yield* new SessionNotFoundError({ sessionId: input.sessionId });
const findSpaceById(input.spaceId).pipe(Effect.orDie);
if (Option.isNone(target)) return yield* reject("space-not-found");
if (target.value.status !== "active") return yield* reject("space-archived");
// Same-project guard. A missing current space means the FK invariant is
// already broken; there is nothing to compare, so the move is allowed.
const current = yield* findSpace(input.sessionId).pipe(Effect.orDie);
if (Option.isSome(current) && current.value.projectId !== target.value.projectId) {
return yield* reject("project-mismatch");
}
const directory =
input.directory ??
(Option.isSome(current)
? rebaseDirectory(current.value.location, target.value.location, session.value.directory)
: target.value.location);
if (!directoryWithin(target.value.location, directory)) return yield* reject("directory-outside-space");
const now = yield* epochNow;
yield* sql`
UPDATE session SET space_id = ${input.spaceId}, directory = ${directory}, updated_at = ${now}
WHERE id = ${input.sessionId}
`.pipe(Effect.orDie);
const updated = yield* findSession(input.sessionId).pipe(Effect.orDie);
if (Option.isNone(updated)) return yield* Effect.die(new Error("session relink did not persist"));
return updated.value;
});

const branch = Effect.fn("Session.branch")(function* (input: { sessionId: string; entryId: string }) {
const moved = yield* sql
.withTransaction(
Expand Down Expand Up @@ -1086,6 +1169,7 @@ export const layer = Layer.effect(
setEntryState,
abortAssistant,
fork,
relink,
branch,
setLabel,
});
Expand Down
3 changes: 2 additions & 1 deletion packages/harness/test/fixtures/space.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface SeedSpaceOptions {
readonly env?: SandboxInstance.ID;
readonly projectId?: string;
readonly kind?: SpaceSchema.Kind;
readonly status?: SpaceSchema.Status;
}

export interface SeededSpace {
Expand All @@ -37,7 +38,7 @@ export const seedSpace = Effect.fn("seedSpace")(function* (options: SeedSpaceOpt
`;
yield* sql`
INSERT OR IGNORE INTO space (id, project_id, location, kind, env, status, created_at, updated_at)
VALUES (${spaceId}, ${projectId}, ${location}, ${options.kind ?? "plain"}, ${SandboxInstance.toColumn(env)}, 'active', 0, 0)
VALUES (${spaceId}, ${projectId}, ${location}, ${options.kind ?? "plain"}, ${SandboxInstance.toColumn(env)}, ${options.status ?? "active"}, 0, 0)
`;
const seeded: SeededSpace = { spaceId, projectId, location, env };
return seeded;
Expand Down
44 changes: 44 additions & 0 deletions packages/harness/test/sdk.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import "./utils/env.ts";
import { Settings } from "../src/settings/settings.ts";
import { Effect, Option } from "effect";
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { describe, expect } from "vite-plus/test";
import { Harness } from "../src/effect/harness.ts";
import { Sandbox } from "../src/effect/sandbox.ts";
Expand Down Expand Up @@ -186,6 +188,48 @@ describe("Harness Effect SDK", () => {
),
);

it.effect("relinks a session to another checkout of the same project", () =>
Effect.acquireUseRelease(
Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "codework-relink-"))),
(base) =>
withHarness(
Effect.gen(function* () {
// Two checkouts of one project: the same origin yields the same
// project id, so a session may move between them.
const exec = promisify(execFile);
const a = path.join(base, "clone-a");
const b = path.join(base, "clone-b");
for (const dir of [a, b]) {
yield* Effect.promise(() => fs.mkdir(dir, { recursive: true }));
yield* Effect.promise(() => exec("git", ["init", "-q", "-b", "main"], { cwd: dir }));
yield* Effect.promise(() =>
exec("git", ["remote", "add", "origin", "git@example.com:org/repo.git"], { cwd: dir }),
);
}
const subA = path.join(a, "packages", "x");
yield* Effect.promise(() => fs.mkdir(subA, { recursive: true }));
const rootB = yield* Effect.promise(() => fs.realpath(b));

// The rebased path exists in the target: the session keeps its
// position under the new root.
yield* Effect.promise(() => fs.mkdir(path.join(b, "packages", "x"), { recursive: true }));
const session = yield* Session.create({ directory: subA });
const moved = yield* Session.relink({ sessionId: session.id, directory: b });
expect((yield* moved.info).directory).toBe(path.join(rootB, "packages", "x"));

// No counterpart under the new root: the session lands on the
// directory it was pointed at instead of stranding on a missing path.
const orphan = path.join(a, "only-in-a");
yield* Effect.promise(() => fs.mkdir(orphan, { recursive: true }));
const other = yield* Session.create({ directory: orphan });
const landed = yield* Session.relink({ sessionId: other.id, directory: b });
expect((yield* landed.info).directory).toBe(rootB);
}),
),
(base) => Effect.promise(() => fs.rm(base, { recursive: true, force: true })),
),
);

it.effect("derives Global children from Harness.layer home", () =>
Effect.acquireUseRelease(
Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "codework-sdk-home-"))),
Expand Down
Loading