Skip to content
Draft
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
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@ff-labs/fff-node": "0.9.4",
"@opencode-ai/sdk": "^1.3.15",
"@pierre/diffs": "catalog:",
"@t3tools/plugin-sdk": "workspace:*",
"effect": "catalog:",
"node-pty": "^1.1.0"
},
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ describe("CheckpointDiffQuery.layer", () => {
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
getThreadOwnerById: () => Effect.succeed(Option.none()),
getThreadCheckpointContext: () =>
Effect.sync(() => {
getThreadCheckpointContextCalls += 1;
Expand Down Expand Up @@ -195,6 +196,7 @@ describe("CheckpointDiffQuery.layer", () => {
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
getThreadOwnerById: () => Effect.succeed(Option.none()),
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
Expand Down Expand Up @@ -277,6 +279,7 @@ describe("CheckpointDiffQuery.layer", () => {
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
getThreadOwnerById: () => Effect.succeed(Option.none()),
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
Expand Down Expand Up @@ -344,6 +347,7 @@ describe("CheckpointDiffQuery.layer", () => {
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
getThreadOwnerById: () => Effect.succeed(Option.none()),
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
Expand Down Expand Up @@ -396,6 +400,7 @@ describe("CheckpointDiffQuery.layer", () => {
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
getThreadOwnerById: () => Effect.succeed(Option.none()),
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type StartupPresentation = typeof StartupPresentation.Type;
export interface ServerDerivedPaths {
readonly stateDir: string;
readonly dbPath: string;
readonly pluginsDir: string;
readonly keybindingsConfigPath: string;
readonly settingsPath: string;
readonly providerStatusCacheDir: string;
Expand Down Expand Up @@ -95,13 +96,15 @@ export const deriveServerPaths = Effect.fn(function* (
const { join } = yield* Path.Path;
const stateDir = join(baseDir, devUrl !== undefined ? "dev" : "userdata");
const dbPath = join(stateDir, "state.sqlite");
const pluginsDir = join(stateDir, "plugins");
const attachmentsDir = join(stateDir, "attachments");
const logsDir = join(stateDir, "logs");
const providerLogsDir = join(logsDir, "provider");
const providerStatusCacheDir = join(baseDir, "caches");
return {
stateDir,
dbPath,
pluginsDir,
keybindingsConfigPath: join(stateDir, "keybindings.json"),
settingsPath: join(stateDir, "settings.json"),
providerStatusCacheDir,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ describe("OrchestrationEngine", () => {
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
getThreadOwnerById: () => Effect.succeed(Option.none()),
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
threadId: event.payload.threadId,
projectId: event.payload.projectId,
title: event.payload.title,
owner: event.payload.owner ?? "user",
modelSelection: event.payload.modelSelection,
runtimeMode: event.payload.runtimeMode,
interactionMode: event.payload.interactionMode,
Expand Down
237 changes: 237 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
id: ThreadId.make("thread-1"),
projectId: asProjectId("project-1"),
title: "Thread 1",
owner: "user",
modelSelection: {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5-codex",
Expand Down Expand Up @@ -696,6 +697,242 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
}),
);

it.effect(
"excludes plugin-owned threads from user-facing lists and startup selection while keeping by-id lookups",
() =>
Effect.gen(function* () {
const snapshotQuery = yield* ProjectionSnapshotQuery;
const sql = yield* SqlClient.SqlClient;

yield* sql`DELETE FROM projection_projects`;
yield* sql`DELETE FROM projection_threads`;
yield* sql`DELETE FROM projection_turns`;
yield* sql`DELETE FROM projection_state`;

yield* sql`
INSERT INTO projection_projects (
project_id,
title,
workspace_root,
default_model_selection_json,
scripts_json,
created_at,
updated_at,
deleted_at
)
VALUES (
'project-owner-filter',
'Owner Filter',
'/tmp/owner-filter',
'{"provider":"codex","model":"gpt-5-codex"}',
'[]',
'2026-04-07T00:00:00.000Z',
'2026-04-07T00:00:01.000Z',
NULL
)
`;

yield* sql`
INSERT INTO projection_threads (
thread_id,
project_id,
title,
owner,
model_selection_json,
runtime_mode,
interaction_mode,
branch,
worktree_path,
latest_turn_id,
latest_user_message_at,
pending_approval_count,
pending_user_input_count,
has_actionable_proposed_plan,
created_at,
updated_at,
archived_at,
deleted_at
)
VALUES
(
'thread-plugin-active',
'project-owner-filter',
'Plugin Active',
'plugin:test',
'{"provider":"codex","model":"gpt-5-codex"}',
'full-access',
'default',
NULL,
'/tmp/plugin-worktree',
NULL,
NULL,
0,
0,
0,
'2026-04-07T00:00:02.000Z',
'2026-04-07T00:00:03.000Z',
NULL,
NULL
),
(
'thread-user-active',
'project-owner-filter',
'User Active',
'user',
'{"provider":"codex","model":"gpt-5-codex"}',
'full-access',
'default',
NULL,
NULL,
NULL,
NULL,
0,
0,
0,
'2026-04-07T00:00:04.000Z',
'2026-04-07T00:00:05.000Z',
NULL,
NULL
),
(
'thread-plugin-archived',
'project-owner-filter',
'Plugin Archived',
'plugin:test',
'{"provider":"codex","model":"gpt-5-codex"}',
'full-access',
'default',
NULL,
NULL,
NULL,
NULL,
0,
0,
0,
'2026-04-07T00:00:06.000Z',
'2026-04-07T00:00:07.000Z',
'2026-04-07T00:00:08.000Z',
NULL
)
`;

yield* sql`
INSERT INTO projection_turns (
thread_id,
turn_id,
pending_message_id,
source_proposed_plan_thread_id,
source_proposed_plan_id,
assistant_message_id,
state,
requested_at,
started_at,
completed_at,
checkpoint_turn_count,
checkpoint_ref,
checkpoint_status,
checkpoint_files_json
)
VALUES (
'thread-plugin-active',
'turn-plugin-1',
NULL,
NULL,
NULL,
NULL,
'completed',
'2026-04-07T00:00:09.000Z',
'2026-04-07T00:00:09.000Z',
'2026-04-07T00:00:09.000Z',
1,
'checkpoint-plugin-1',
'ready',
'[]'
)
`;

yield* sql`
INSERT INTO projection_state (projector, last_applied_sequence, updated_at)
VALUES
(${ORCHESTRATION_PROJECTOR_NAMES.projects}, 6, '2026-04-07T00:00:10.000Z'),
(${ORCHESTRATION_PROJECTOR_NAMES.threads}, 6, '2026-04-07T00:00:10.000Z'),
(${ORCHESTRATION_PROJECTOR_NAMES.threadMessages}, 6, '2026-04-07T00:00:10.000Z'),
(${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 6, '2026-04-07T00:00:10.000Z'),
(${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 6, '2026-04-07T00:00:10.000Z'),
(${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 6, '2026-04-07T00:00:10.000Z'),
(${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 6, '2026-04-07T00:00:10.000Z')
`;

const counts = yield* snapshotQuery.getCounts();
assert.deepEqual(counts, {
projectCount: 1,
threadCount: 1,
});

// The decider's read model keeps plugin-owned threads: commands and
// events on them must still validate after a restart.
const commandReadModel = yield* snapshotQuery.getCommandReadModel();
assert.deepEqual(
commandReadModel.threads.map((thread) => thread.id),
[
ThreadId.make("thread-plugin-active"),
ThreadId.make("thread-user-active"),
ThreadId.make("thread-plugin-archived"),
],
);

const fullSnapshot = yield* snapshotQuery.getSnapshot();
assert.deepEqual(
fullSnapshot.threads.map((thread) => thread.id),
[ThreadId.make("thread-user-active")],
);

const shellSnapshot = yield* snapshotQuery.getShellSnapshot();
assert.deepEqual(
shellSnapshot.threads.map((thread) => thread.id),
[ThreadId.make("thread-user-active")],
);

const archivedShellSnapshot = yield* snapshotQuery.getArchivedShellSnapshot();
assert.deepEqual(
archivedShellSnapshot.threads.map((thread) => thread.id),
[],
);

const firstThreadId = yield* snapshotQuery.getFirstActiveThreadIdByProjectId(
asProjectId("project-owner-filter"),
);
assert.equal(firstThreadId._tag, "Some");
if (firstThreadId._tag === "Some") {
assert.equal(firstThreadId.value, ThreadId.make("thread-user-active"));
}

const pluginShell = yield* snapshotQuery.getThreadShellById(
ThreadId.make("thread-plugin-active"),
);
assert.equal(pluginShell._tag, "Some");

const pluginDetail = yield* snapshotQuery.getThreadDetailById(
ThreadId.make("thread-plugin-active"),
);
assert.equal(pluginDetail._tag, "Some");
if (pluginDetail._tag === "Some") {
assert.equal(pluginDetail.value.owner, "plugin:test");
}

const checkpointContext = yield* snapshotQuery.getThreadCheckpointContext(
ThreadId.make("thread-plugin-active"),
);
assert.equal(checkpointContext._tag, "Some");

const fullDiffContext = yield* snapshotQuery.getFullThreadDiffContext(
ThreadId.make("thread-plugin-active"),
1,
);
assert.equal(fullDiffContext._tag, "Some");
}),
);

it.effect("reads single-thread checkpoint context without hydrating unrelated threads", () =>
Effect.gen(function* () {
const snapshotQuery = yield* ProjectionSnapshotQuery;
Expand Down
Loading
Loading