diff --git a/packages/client/script/generate.ts b/packages/client/script/generate.ts index 1b3395cc7e23..f736cb241dbd 100644 --- a/packages/client/script/generate.ts +++ b/packages/client/script/generate.ts @@ -32,6 +32,7 @@ import { Question } from "@opencode/schema/question" import { Reference } from "@opencode/schema/reference" import { AbsolutePath, RelativePath } from "@opencode/schema/schema" import { Session } from "@opencode/schema/session" +import { SessionGoal } from "@opencode/schema/session-goal" import { SessionMessage } from "@opencode/schema/session-message" import { SessionInbox } from "@opencode/schema/session-inbox" import { Shell } from "@opencode/schema/shell" @@ -77,6 +78,7 @@ const effectTypeReferences = [ ...namespaceTypes("Question", "@opencode/schema/question", Question), ...namespaceTypes("Reference", "@opencode/schema/reference", Reference), ...namespaceTypes("Session", "@opencode/schema/session", Session), + ...namespaceTypes("SessionGoal", "@opencode/schema/session-goal", SessionGoal), ...namespaceTypes("SessionMessage", "@opencode/schema/session-message", SessionMessage), ...namespaceTypes("SessionInbox", "@opencode/schema/session-inbox", SessionInbox), ...namespaceTypes("Shell", "@opencode/schema/shell", Shell), diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 3c80f44505d3..2278cdbb386e 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -23,6 +23,7 @@ import type { Event } from "@opencode/schema/event" import type { EventLog } from "@opencode/schema/event-log" import type { Workspace } from "@opencode/schema/workspace" import type { Instruction } from "@opencode/schema/instruction" +import type { SessionGoal } from "@opencode/schema/session-goal" import type { Snapshot } from "@opencode/schema/snapshot" import type { Shell } from "@opencode/schema/shell" import type { Form } from "@opencode/schema/form" @@ -729,6 +730,58 @@ export type SessionLogOutput = readonly text?: string | undefined } } + | { + readonly id: Event.ID + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.goal.updated" + readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } + readonly location?: + | { readonly directory: AbsolutePath; readonly workspaceID?: Workspace.ID | undefined } + | undefined + readonly data: { readonly sessionID: Session.ID; readonly snapshot: SessionGoal.Snapshot } + } + | { + readonly id: Event.ID + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.goal.cleared" + readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } + readonly location?: + | { readonly directory: AbsolutePath; readonly workspaceID?: Workspace.ID | undefined } + | undefined + readonly data: { readonly sessionID: Session.ID; readonly goalID: SessionGoal.ID; readonly revision: number } + } + | { + readonly id: Event.ID + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.todo.updated" + readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } + readonly location?: + | { readonly directory: AbsolutePath; readonly workspaceID?: Workspace.ID | undefined } + | undefined + readonly data: { + readonly sessionID: Session.ID + readonly revision: number + readonly todos: ReadonlyArray + } + } + | { + readonly id: Event.ID + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.todo.verified" + readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } + readonly location?: + | { readonly directory: AbsolutePath; readonly workspaceID?: Workspace.ID | undefined } + | undefined + readonly data: { + readonly sessionID: Session.ID + readonly revision: number + readonly items: ReadonlyArray + } + } | { readonly id: Event.ID readonly created: number @@ -1262,6 +1315,33 @@ export type SessionBackgroundOperation = ( input: SessionBackgroundInput, ) => Effect.Effect +export type SessionGoalGetInput = { readonly sessionID: Session.ID } +export type SessionGoalGetOutput = SessionGoal.State +export type SessionGoalGetOperation = (input: SessionGoalGetInput) => Effect.Effect + +export type SessionGoalUpdateInput = { + readonly sessionID: Session.ID + readonly action: "set" | "clear" | "edit" | "pause" | "resume" + readonly objective?: string | undefined + readonly expectedRevision?: number | undefined + readonly expectedGoalID?: SessionGoal.ID | undefined +} +export type SessionGoalUpdateOutput = SessionGoal.Snapshot +export type SessionGoalUpdateOperation = ( + input: SessionGoalUpdateInput, +) => Effect.Effect + +export type SessionTodoReplaceInput = { + readonly sessionID: Session.ID + readonly todos: ReadonlyArray + readonly expectedRevision?: number | undefined + readonly expectedGoalID?: SessionGoal.ID | undefined +} +export type SessionTodoReplaceOutput = void +export type SessionTodoReplaceOperation = ( + input: SessionTodoReplaceInput, +) => Effect.Effect + export type SessionMessageGetInput = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID } export type SessionMessageGetOutput = SessionMessage.Info export type SessionMessageGetOperation = ( @@ -1367,6 +1447,8 @@ export interface SessionApi { readonly log: SessionLogOperation readonly interrupt: SessionInterruptOperation readonly background: SessionBackgroundOperation + readonly goal: { readonly get: SessionGoalGetOperation; readonly update: SessionGoalUpdateOperation } + readonly todo: { readonly replace: SessionTodoReplaceOperation } readonly message: { readonly get: SessionMessageGetOperation } readonly form: { readonly list: SessionFormListOperation @@ -1959,6 +2041,10 @@ export type EventSubscribeInput = { | "session.execution.failed" | "session.execution.interrupted" | "session.instructions.updated" + | "session.goal.updated" + | "session.goal.cleared" + | "session.todo.updated" + | "session.todo.verified" | "session.synthetic" | "session.skill.activated" | "session.shell.started" diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index a7d53fd2b025..e9ec9148b522 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -93,6 +93,12 @@ import type { SessionInterruptOutput, SessionBackgroundInput, SessionBackgroundOutput, + SessionGoalGetInput, + SessionGoalGetOutput, + SessionGoalUpdateInput, + SessionGoalUpdateOutput, + SessionTodoReplaceInput, + SessionTodoReplaceOutput, SessionMessageGetInput, SessionMessageGetOutput, SessionFormListInput, @@ -742,6 +748,42 @@ const EndpointSessionBackground = (raw: RawClient["server.session"]) => (input: raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), ) +const EndpointSessionGoalGet = (raw: RawClient["server.session"]) => (input: SessionGoalGetInput) => + preserveEffect()( + raw["session.goal.get"]({ params: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ), + ) + +const EndpointSessionGoalUpdate = (raw: RawClient["server.session"]) => (input: SessionGoalUpdateInput) => + preserveEffect()( + raw["session.goal.update"]({ + params: { sessionID: input["sessionID"] }, + payload: { + action: input["action"], + objective: input["objective"], + expectedRevision: input["expectedRevision"], + expectedGoalID: input["expectedGoalID"], + }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ), + ) + +const EndpointSessionTodoReplace = (raw: RawClient["server.session"]) => (input: SessionTodoReplaceInput) => + preserveEffect()( + raw["session.todo.replace"]({ + params: { sessionID: input["sessionID"] }, + payload: { + todos: input["todos"], + expectedRevision: input["expectedRevision"], + expectedGoalID: input["expectedGoalID"], + }, + }).pipe(Effect.mapError(mapClientError)), + ) + const EndpointSessionMessageGet = (raw: RawClient["server.session"]) => (input: SessionMessageGetInput) => preserveEffect()( raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( @@ -852,6 +894,8 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({ log: EndpointSessionLog(raw), interrupt: EndpointSessionInterrupt(raw), background: EndpointSessionBackground(raw), + goal: { get: EndpointSessionGoalGet(raw), update: EndpointSessionGoalUpdate(raw) }, + todo: { replace: EndpointSessionTodoReplace(raw) }, message: { get: EndpointSessionMessageGet(raw) }, form: { list: EndpointSessionFormList(raw), diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 32aec4b71f19..c8b442a69087 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -87,6 +87,12 @@ import type { SessionInterruptOutput, SessionBackgroundInput, SessionBackgroundOutput, + SessionGoalGetInput, + SessionGoalGetOutput, + SessionGoalUpdateInput, + SessionGoalUpdateOutput, + SessionTodoReplaceInput, + SessionTodoReplaceOutput, SessionMessageGetInput, SessionMessageGetOutput, SessionFormListInput, @@ -1041,6 +1047,54 @@ export function make(options: ClientOptions) { }, requestOptions, ), + goal: { + get: (input: SessionGoalGetInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionGoalGetOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/goal`, + successStatus: 200, + declaredStatuses: [400, 401, 404], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + update: (input: SessionGoalUpdateInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionGoalUpdateOutput }>( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/goal`, + body: { + action: input["action"], + objective: input["objective"], + expectedRevision: input["expectedRevision"], + expectedGoalID: input["expectedGoalID"], + }, + successStatus: 200, + declaredStatuses: [400, 401, 404, 409], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + }, + todo: { + replace: (input: SessionTodoReplaceInput, requestOptions?: RequestOptions) => + request( + { + method: "PUT", + path: `/api/session/${encodeURIComponent(input.sessionID)}/todo`, + body: { + todos: input["todos"], + expectedRevision: input["expectedRevision"], + expectedGoalID: input["expectedGoalID"], + }, + successStatus: 204, + declaredStatuses: [400, 401, 404, 409], + empty: true, + }, + requestOptions, + ), + }, message: { get: (input: SessionMessageGetInput, requestOptions?: RequestOptions) => request<{ readonly data: SessionMessageGetOutput }>( diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 9d4db667f963..ab040cf009fe 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -148,6 +148,22 @@ export type InstructionHash = string export type SessionInboxSyntheticPayload1 = { text: string; description?: string; metadata?: { [x: string]: any } } +export type SessionGoalID = string + +export type SessionGoalReason = { code: string; message: string } + +export type SessionGoalTodoInput = { + content: string + status: "pending" | "in_progress" | "completed" | "cancelled" + acceptance?: string +} + +export type SessionGoalTodoVerification = { + position: number + status: "pending" | "in_progress" | "completed" | "cancelled" + feedback: string +} + export type SessionMessageNative1 = { [x: string]: any } export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?: string | undefined } @@ -160,6 +176,14 @@ export type SessionMessageToolStateRunning1 = { export type SessionInterruptResponse = { interrupted: boolean } +export type SessionGoalTodoItem = { + position: number | "Infinity" | "-Infinity" | "NaN" + content: string + status: "pending" | "in_progress" | "completed" | "cancelled" + acceptance?: string + feedback?: string +} + export type FormID = string export type FormMetadata = { [x: string]: JsonValue } @@ -564,6 +588,24 @@ export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: Ev export type LocationRef = { directory: AbsolutePath; workspaceID?: WorkspaceID } +export type SessionGoalSnapshot = { + id: SessionGoalID + revision: number + objective: string + phase: "active" | "paused" | "blocked" | "complete" + blockedReason?: SessionGoalReason + maxGoalRounds: number +} + +export type SessionGoalSnapshot1 = { + id: SessionGoalID + revision: number | "Infinity" | "-Infinity" | "NaN" + objective: string + phase: "active" | "paused" | "blocked" | "complete" + blockedReason?: SessionGoalReason + maxGoalRounds: number | "Infinity" | "-Infinity" | "NaN" +} + export type SessionMessageAssistantText1 = { type: "text"; text: string; native?: SessionMessageNative1 } export type SessionMessageAssistantReasoning1 = { @@ -1236,6 +1278,36 @@ export type SessionInstructionsUpdated = { data: { sessionID: SessionID; delta: { [x: string]: InstructionHash | "removed" }; text?: string } } +export type SessionGoalCleared = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "session.goal.cleared" + durable: { aggregateID: string; seq: EventSeq; version: 1 } + location?: LocationRef + data: { sessionID: SessionID; goalID: SessionGoalID; revision: number } +} + +export type SessionTodoUpdated = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "session.todo.updated" + durable: { aggregateID: string; seq: EventSeq; version: 1 } + location?: LocationRef + data: { sessionID: SessionID; revision: number; todos: Array } +} + +export type SessionTodoVerified = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "session.todo.verified" + durable: { aggregateID: string; seq: EventSeq; version: 1 } + location?: LocationRef + data: { sessionID: SessionID; revision: number; items: Array } +} + export type SessionSynthetic = { id: EventID created: number @@ -1977,6 +2049,25 @@ export type McpResourcesChanged = { data: { server: string } } +export type SessionGoalUpdated = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "session.goal.updated" + durable: { aggregateID: string; seq: EventSeq; version: 1 } + location?: LocationRef + data: { sessionID: SessionID; snapshot: SessionGoalSnapshot } +} + +export type SessionGoalState = { + goal?: SessionGoalSnapshot1 + createdAt?: number | "Infinity" | "-Infinity" | "NaN" + rounds: number | "Infinity" | "-Infinity" | "NaN" + maxGoalRounds: number | "Infinity" | "-Infinity" | "NaN" + armed: boolean + todo: Array +} + export type SessionToolSuccess = { id: EventID created: number @@ -2642,6 +2733,10 @@ export type SessionEventDurable = | SessionExecutionFailed | SessionExecutionInterrupted | SessionInstructionsUpdated + | SessionGoalUpdated + | SessionGoalCleared + | SessionTodoUpdated + | SessionTodoVerified | SessionSynthetic | SessionSkillActivated | SessionShellStarted @@ -2708,6 +2803,10 @@ export type V2Event = | SessionExecutionFailed | SessionExecutionInterrupted | SessionInstructionsUpdated + | SessionGoalUpdated + | SessionGoalCleared + | SessionTodoUpdated + | SessionTodoVerified | SessionSynthetic | SessionSkillActivated | SessionShellStarted @@ -3446,6 +3545,33 @@ export type SessionBackgroundInput = { readonly sessionID: string } export type SessionBackgroundOutput = void +export type SessionGoalGetInput = { readonly sessionID: string } + +export type SessionGoalGetOutput = { data: SessionGoalState }["data"] + +export type SessionGoalUpdateInput = { + readonly sessionID: string + readonly action: "set" | "clear" | "edit" | "pause" | "resume" + readonly objective?: string | undefined + readonly expectedRevision?: number | undefined + readonly expectedGoalID?: string | undefined +} + +export type SessionGoalUpdateOutput = { data: SessionGoalSnapshot1 }["data"] + +export type SessionTodoReplaceInput = { + readonly sessionID: string + readonly todos: ReadonlyArray<{ + readonly content: string + readonly status: "pending" | "in_progress" | "completed" | "cancelled" + readonly acceptance?: string + }> + readonly expectedRevision?: number | "Infinity" | "-Infinity" | "NaN" | null | undefined + readonly expectedGoalID?: string | null | undefined +} + +export type SessionTodoReplaceOutput = void + export type SessionMessageGetInput = { readonly sessionID: string; readonly messageID: string } export type SessionMessageGetOutput = { data: SessionMessageInfo }["data"] @@ -4169,6 +4295,10 @@ export type EventSubscribeInput = { | "session.execution.failed" | "session.execution.interrupted" | "session.instructions.updated" + | "session.goal.updated" + | "session.goal.cleared" + | "session.todo.updated" + | "session.todo.verified" | "session.synthetic" | "session.skill.activated" | "session.shell.started" diff --git a/packages/core/schema.json b/packages/core/schema.json index 711154c879ad..c4f6677f249d 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,8 +1,8 @@ { "version": "7", "dialect": "sqlite", - "id": "44bd8ca5-689a-410e-a46a-958eb5accd97", - "prevIds": ["f1f14616-b6a1-483d-87b0-d5b7a9132580"], + "id": "ada3c5df-aa2e-4ad7-bc13-2f6f5b166cdf", + "prevIds": ["44bd8ca5-689a-410e-a46a-958eb5accd97"], "ddl": [ { "name": "account_state", @@ -64,6 +64,10 @@ "name": "instruction_state", "entityType": "tables" }, + { + "name": "session_goal", + "entityType": "tables" + }, { "name": "session_inbox", "entityType": "tables" @@ -76,6 +80,10 @@ "name": "session_v2", "entityType": "tables" }, + { + "name": "session_todo", + "entityType": "tables" + }, { "name": "workspace", "entityType": "tables" @@ -984,6 +992,106 @@ "entityType": "columns", "table": "instruction_state" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_goal" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "goal_id", + "entityType": "columns", + "table": "session_goal" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revision", + "entityType": "columns", + "table": "session_goal" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "objective", + "entityType": "columns", + "table": "session_goal" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "phase", + "entityType": "columns", + "table": "session_goal" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "blocked_reason", + "entityType": "columns", + "table": "session_goal" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "8", + "generated": null, + "name": "max_goal_rounds", + "entityType": "columns", + "table": "session_goal" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "rounds", + "entityType": "columns", + "table": "session_goal" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_goal" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_goal" + }, { "type": "text", "notNull": false, @@ -1484,6 +1592,86 @@ "entityType": "columns", "table": "session_v2" }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "session_todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "session_todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "session_todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "acceptance", + "entityType": "columns", + "table": "session_todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "feedback", + "entityType": "columns", + "table": "session_todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_todo" + }, { "type": "text", "notNull": false, @@ -1662,6 +1850,17 @@ "entityType": "fks", "table": "instruction_state" }, + { + "columns": ["session_id"], + "tableTo": "session_v2", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_goal_session_id_session_v2_id_fk", + "entityType": "fks", + "table": "session_goal" + }, { "columns": ["session_id"], "tableTo": "session_v2", @@ -1695,6 +1894,17 @@ "entityType": "fks", "table": "session_v2" }, + { + "columns": ["session_id"], + "tableTo": "session_v2", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_todo_session_id_session_v2_id_fk", + "entityType": "fks", + "table": "session_todo" + }, { "columns": ["project_id"], "tableTo": "project", @@ -1734,6 +1944,13 @@ "entityType": "pks", "table": "instruction_entry" }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "session_todo_pk", + "entityType": "pks", + "table": "session_todo" + }, { "columns": ["project_id", "directory"], "nameExplicit": false, @@ -1818,6 +2035,13 @@ "table": "instruction_state", "entityType": "pks" }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_goal_pk", + "table": "session_goal", + "entityType": "pks" + }, { "columns": ["id"], "nameExplicit": false, diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 712c88bad4a1..0d6831370da4 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -49,10 +49,11 @@ import m46 from "./migration/20260910120000_clear_v1_session_permission.js" import m47 from "./migration/20260923013825_project_time_active.js" import m48 from "./migration/20261007190000_azure_cli_external_credential.js" import m49 from "./migration/20261010180456_project_time_archived.js" -import m50 from "./migration/20261024002554_session_allowance.js" -import m51 from "./migration/20261024175301_drop_session_allowance.js" -import m52 from "./migration/20261025123711_blob.js" -import m53 from "./migration/20261025154125_drop_session_pending.js" +import m50 from "./migration/20261012112921_modern_red_wolf.js" +import m51 from "./migration/20261024002554_session_allowance.js" +import m52 from "./migration/20261024175301_drop_session_allowance.js" +import m53 from "./migration/20261025123711_blob.js" +import m54 from "./migration/20261025154125_drop_session_pending.js" export const migrations = [ m00, @@ -109,4 +110,5 @@ export const migrations = [ m51, m52, m53, + m54, ] satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20261012112921_modern_red_wolf.ts b/packages/core/src/database/migration/20261012112921_modern_red_wolf.ts new file mode 100644 index 000000000000..f3caec2f2931 --- /dev/null +++ b/packages/core/src/database/migration/20261012112921_modern_red_wolf.ts @@ -0,0 +1,41 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration.js" + +const migration: DatabaseMigration.Migration = { + id: "20261012112921_modern_red_wolf", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`session_goal\` ( + \`session_id\` text PRIMARY KEY, + \`goal_id\` text NOT NULL, + \`revision\` integer NOT NULL, + \`objective\` text NOT NULL, + \`phase\` text NOT NULL, + \`blocked_reason\` text, + \`max_goal_rounds\` integer DEFAULT 8 NOT NULL, + \`rounds\` integer DEFAULT 0 NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + CONSTRAINT \`fk_session_goal_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(` + CREATE TABLE \`session_todo\` ( + \`session_id\` text NOT NULL, + \`position\` integer NOT NULL, + \`content\` text NOT NULL, + \`status\` text NOT NULL, + \`acceptance\` text, + \`feedback\` text, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + CONSTRAINT \`session_todo_pk\` PRIMARY KEY(\`session_id\`, \`position\`), + CONSTRAINT \`fk_session_todo_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE + ); + `) + }) + }, +} + +export default migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 9de9d86990c3..b4ed2b30da55 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -166,6 +166,21 @@ const schema: Omit = { CONSTRAINT \`fk_instruction_state_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE ); `) + yield* tx.run(` + CREATE TABLE \`session_goal\` ( + \`session_id\` text PRIMARY KEY, + \`goal_id\` text NOT NULL, + \`revision\` integer NOT NULL, + \`objective\` text NOT NULL, + \`phase\` text NOT NULL, + \`blocked_reason\` text, + \`max_goal_rounds\` integer DEFAULT 8 NOT NULL, + \`rounds\` integer DEFAULT 0 NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + CONSTRAINT \`fk_session_goal_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE + ); + `) yield* tx.run(` CREATE TABLE \`session_inbox\` ( \`id\` text PRIMARY KEY, @@ -231,6 +246,20 @@ const schema: Omit = { CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE ); `) + yield* tx.run(` + CREATE TABLE \`session_todo\` ( + \`session_id\` text NOT NULL, + \`position\` integer NOT NULL, + \`content\` text NOT NULL, + \`status\` text NOT NULL, + \`acceptance\` text, + \`feedback\` text, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + CONSTRAINT \`session_todo_pk\` PRIMARY KEY(\`session_id\`, \`position\`), + CONSTRAINT \`fk_session_todo_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE + ); + `) yield* tx.run(` CREATE TABLE \`workspace\` ( \`id\` text PRIMARY KEY, diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index 1f7d590b3e52..106242322c1d 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -5,6 +5,7 @@ import { Effect, Stream } from "effect" import { Bus } from "../bus.js" import { Location } from "../location.js" import { Mcp } from "../mcp/index.js" +import { SessionGoal } from "../session/goal.js" import PROMPT_INITIALIZE from "./command/initialize.txt" import PROMPT_REVIEW from "./command/review.txt" @@ -14,6 +15,7 @@ export const Plugin = define({ const location = yield* Location.Service const mcp = yield* Mcp.Service const bus = yield* Bus.Service + const goal = yield* SessionGoal.Service const loaded = { prompts: [] as Mcp.Prompt[] } yield* bus.subscribe(Mcp.PromptsChanged).pipe( Stream.runForEach(() => @@ -52,6 +54,12 @@ export const Plugin = define({ }) .pipe(Effect.asVoid), }) + editor.add({ + name: "goal", + description: "set, clear, edit, pause, or resume the session goal", + execute: (input) => + goal.update(input.sessionID, parseGoal(input.prompt.text)).pipe(Effect.asVoid), + }) for (const prompt of loaded.prompts) { editor.add({ name: mcpCommandName(prompt.server, prompt.name), @@ -89,6 +97,18 @@ function append(template: string, input: string) { return [template, value].filter(Boolean).join("\n\n") } +/** + * `/goal [objective|clear|edit |pause|resume]`; a bare objective sets + * the goal. The subcommands match exactly, so `/goal clear the flaky tests` sets + * that objective instead of clearing the goal. + */ +function parseGoal(text: string): SessionGoal.UpdateInput { + const trimmed = text.trim() + if (trimmed === "clear" || trimmed === "pause" || trimmed === "resume") return { action: trimmed } + if (trimmed === "edit" || trimmed.startsWith("edit ")) return { action: "edit", objective: trimmed.slice(4).trim() } + return { action: "set", objective: trimmed } +} + function parseArguments(input: string) { return (input.match(argsRegex) ?? []).map((argument) => argument.replace(quoteTrimRegex, "")) } diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index e0421b9c5802..44d6b8eb9814 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -59,6 +59,8 @@ import { Reference } from "../reference.js" import { WebSearch } from "../websearch.js" import { Ripgrep } from "../ripgrep.js" import { Session } from "../session.js" +import { SessionGoal } from "../session/goal.js" +import { SessionGoalActivation } from "../session/goal-activation.js" import { SessionCompaction } from "../session/compaction.js" import { SessionInstructions } from "../session/instructions.js" import { SubagentJob } from "../session/subagent-job.js" @@ -142,6 +144,8 @@ const services = [ WebSearch.Service, Ripgrep.Service, Session.Service, + SessionGoal.Service, + SessionGoalActivation.Service, SessionCompaction.Service, SessionInstructions.Service, SubagentJob.Service, @@ -196,6 +200,8 @@ export const requirements = LayerNode.group([ WebSearch.node, Ripgrep.node, Session.node, + SessionGoal.node, + SessionGoalActivation.node, SessionCompaction.node, SessionInstructions.node, SubagentJob.node, diff --git a/packages/core/src/session/goal-activation.ts b/packages/core/src/session/goal-activation.ts new file mode 100644 index 000000000000..1e252c83bf63 --- /dev/null +++ b/packages/core/src/session/goal-activation.ts @@ -0,0 +1,33 @@ +export * as SessionGoalActivation from "./goal-activation.js" + +import { Context, Effect, Layer } from "effect" +import { makeGlobalNode } from "@opencode/util/effect/app-node" +import { SessionSchema } from "./schema.js" + +/** + * Process-local willingness to run a goal. Never persisted, so a fresh process or a + * reloaded instance starts disarmed and a goal never resumes itself after restart. + * `armed` is the same kind of process-local truth as `session.interrupt`'s + * `interrupted` flag: it only means anything to the process that owns it. + */ +export interface Interface { + readonly arm: (sessionID: SessionSchema.ID) => Effect.Effect + readonly disarm: (sessionID: SessionSchema.ID) => Effect.Effect + readonly isArmed: (sessionID: SessionSchema.ID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/SessionGoalActivation") {} + +const layer = Layer.effect( + Service, + Effect.sync(() => { + const armed = new Set() + return Service.of({ + arm: (sessionID) => Effect.sync(() => void armed.add(sessionID)), + disarm: (sessionID) => Effect.sync(() => void armed.delete(sessionID)), + isArmed: (sessionID) => Effect.sync(() => armed.has(sessionID)), + }) + }), +) + +export const node = makeGlobalNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/session/goal.ts b/packages/core/src/session/goal.ts new file mode 100644 index 000000000000..ad983877c41f --- /dev/null +++ b/packages/core/src/session/goal.ts @@ -0,0 +1,386 @@ +export * as SessionGoal from "./goal.js" + +import { and, asc, eq, sql } from "drizzle-orm" +import { Context, Effect, Layer, Schema } from "effect" +import { makeGlobalNode } from "@opencode/util/effect/app-node" +import { Bus } from "../bus.js" +import { Database } from "../database/database.js" +import { KeyedMutex } from "../effect/keyed-mutex.js" +import { SessionGoal as SessionGoalSchema } from "@opencode/schema/session-goal" +import { SessionEvent } from "./event.js" +import { SessionExecution } from "./execution.js" +import { SessionGoalActivation } from "./goal-activation.js" +import { SessionGoalTable, SessionTodoTable } from "./sql.js" +import type { SessionSchema } from "./schema.js" + +type DatabaseService = Database.Interface["db"] + +/** Rounds a goal may run before it is pushed to `blocked`; the driver enforces it. */ +export const DefaultMaxGoalRounds = 8 + +/** + * Upsert the current goal. The bus projects each committed event exactly once and in + * order, so the latest event always wins and no revision guard is needed. A new goal + * identity starts a fresh todo list, so the previous goal's list is dropped. + */ +export const projectUpdated = Effect.fn("SessionGoal.projectUpdated")(function* ( + db: DatabaseService, + input: { + readonly sessionID: SessionSchema.ID + readonly snapshot: SessionGoalSchema.Snapshot + }, +) { + const values = { + session_id: input.sessionID, + goal_id: input.snapshot.id, + revision: input.snapshot.revision, + objective: input.snapshot.objective, + phase: input.snapshot.phase, + blocked_reason: input.snapshot.blockedReason ?? null, + max_goal_rounds: input.snapshot.maxGoalRounds, + } + const current = yield* db + .select({ goal_id: SessionGoalTable.goal_id }) + .from(SessionGoalTable) + .where(eq(SessionGoalTable.session_id, input.sessionID)) + .get() + .pipe(Effect.orDie) + if (current && current.goal_id !== input.snapshot.id) yield* clearTodos(db, input.sessionID) + yield* db + .insert(SessionGoalTable) + .values(values) + .onConflictDoUpdate({ target: SessionGoalTable.session_id, set: values }) + .run() + .pipe(Effect.orDie) +}) + +/** Delete the goal row and its todo list. The tombstone stays in the event log for audit. */ +export const projectCleared = Effect.fn("SessionGoal.projectCleared")(function* ( + db: DatabaseService, + input: { + readonly sessionID: SessionSchema.ID + readonly goalID: SessionGoalSchema.ID + }, +) { + yield* clearTodos(db, input.sessionID) + yield* db + .delete(SessionGoalTable) + .where(and(eq(SessionGoalTable.session_id, input.sessionID), eq(SessionGoalTable.goal_id, input.goalID))) + .run() + .pipe(Effect.orDie) +}) + +/** + * Replace the whole list and advance the goal revision, so a stale verification is + * rejected. The revision is the same counter a verification is judged against, so an + * event that does not advance it is dropped: letting it through would rewind the goal + * to a number an already-stale verdict carries. A list only exists under a goal, so an + * event with no goal row is dropped as well instead of writing a list no verdict can match. + */ +export const projectTodoUpdated = Effect.fn("SessionGoal.projectTodoUpdated")(function* ( + db: DatabaseService, + input: { + readonly sessionID: SessionSchema.ID + readonly revision: number + readonly todos: ReadonlyArray + }, +) { + const goal = yield* db + .select({ revision: SessionGoalTable.revision }) + .from(SessionGoalTable) + .where(eq(SessionGoalTable.session_id, input.sessionID)) + .get() + .pipe(Effect.orDie) + if (!goal || input.revision <= goal.revision) return + yield* clearTodos(db, input.sessionID) + if (input.todos.length > 0) + yield* db + .insert(SessionTodoTable) + .values( + input.todos.map((todo, position) => ({ + session_id: input.sessionID, + position, + content: todo.content, + status: todo.status, + acceptance: todo.acceptance ?? null, + })), + ) + .run() + .pipe(Effect.orDie) + yield* db + .update(SessionGoalTable) + .set({ revision: input.revision }) + .where(eq(SessionGoalTable.session_id, input.sessionID)) + .run() + .pipe(Effect.orDie) +}) + +/** + * Apply one verifier verdict. The verdict carries the goal revision it judged; a + * stale revision (the list changed since) is rejected, and rounds only advance when + * a goal row exists. + */ +export const projectTodoVerified = Effect.fn("SessionGoal.projectTodoVerified")(function* ( + db: DatabaseService, + input: { + readonly sessionID: SessionSchema.ID + readonly revision: number + readonly items: ReadonlyArray + }, +) { + const goal = yield* db + .select({ revision: SessionGoalTable.revision }) + .from(SessionGoalTable) + .where(eq(SessionGoalTable.session_id, input.sessionID)) + .get() + .pipe(Effect.orDie) + if (!goal || goal.revision !== input.revision) return + yield* Effect.forEach( + input.items, + (item) => + db + .update(SessionTodoTable) + .set({ status: item.status, feedback: item.feedback }) + .where(and(eq(SessionTodoTable.session_id, input.sessionID), eq(SessionTodoTable.position, item.position))) + .run() + .pipe(Effect.orDie), + { discard: true }, + ) + yield* db + .update(SessionGoalTable) + .set({ rounds: sql`${SessionGoalTable.rounds} + 1` }) + .where(eq(SessionGoalTable.session_id, input.sessionID)) + .run() + .pipe(Effect.orDie) +}) + +function clearTodos(db: DatabaseService, sessionID: SessionSchema.ID) { + return db.delete(SessionTodoTable).where(eq(SessionTodoTable.session_id, sessionID)).run().pipe(Effect.orDie) +} + +export const read = Effect.fn("SessionGoal.read")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + const row = yield* db + .select() + .from(SessionGoalTable) + .where(eq(SessionGoalTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) + return row ? fromRow(row) : undefined +}) + +export const readTodo = Effect.fn("SessionGoal.readTodo")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + const rows = yield* db + .select() + .from(SessionTodoTable) + .where(eq(SessionTodoTable.session_id, sessionID)) + .orderBy(asc(SessionTodoTable.position)) + .all() + .pipe(Effect.orDie) + return rows.map((row) => ({ + position: row.position, + content: row.content, + status: row.status, + acceptance: row.acceptance ?? undefined, + feedback: row.feedback ?? undefined, + })) +}) + +function fromRow(row: typeof SessionGoalTable.$inferSelect): SessionGoalSchema.Snapshot { + return { + id: row.goal_id, + revision: row.revision, + objective: row.objective, + phase: row.phase, + blockedReason: row.blocked_reason ?? undefined, + maxGoalRounds: row.max_goal_rounds, + } +} + +/** A goal mutation rejected because the goal is missing, in the wrong phase, or stale. */ +export class ConflictError extends Schema.TaggedError()("SessionGoal.ConflictError", { + message: Schema.String, +}) {} + +export interface UpdateInput { + readonly action: "set" | "clear" | "edit" | "pause" | "resume" + readonly objective?: string + /** CAS: the revision the client last read. Supply `expectedGoalID` with it. */ + readonly expectedRevision?: number + /** CAS: the goal identity the client last read. A missing id never matches. */ + readonly expectedGoalID?: SessionGoalSchema.ID +} + +/** + * The compare-and-set token. A revision alone is not enough: `set` starts a new + * goal at revision 1, so a client holding revision 1 of the previous goal would + * otherwise pass against a different goal. The pair is compared together. + */ +export interface Expected { + readonly revision: number + /** A missing id never matches an existing goal, so a client that omits it fails closed. */ + readonly goalID: SessionGoalSchema.ID | undefined +} + +export interface Interface { + readonly read: (sessionID: SessionSchema.ID) => Effect.Effect + readonly readTodo: (sessionID: SessionSchema.ID) => Effect.Effect> + readonly readState: (sessionID: SessionSchema.ID) => Effect.Effect + readonly update: ( + sessionID: SessionSchema.ID, + input: UpdateInput, + ) => Effect.Effect + readonly replaceTodo: ( + sessionID: SessionSchema.ID, + todos: ReadonlyArray, + expected?: Expected, + ) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/SessionGoal") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const { db } = yield* Database.Service + const bus = yield* Bus.Service + const execution = yield* SessionExecution.Service + const activation = yield* SessionGoalActivation.Service + + const readState = Effect.fn("SessionGoal.readState")(function* (sessionID: SessionSchema.ID) { + const row = yield* db + .select() + .from(SessionGoalTable) + .where(eq(SessionGoalTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) + return { + goal: row ? fromRow(row) : undefined, + createdAt: row?.time_created, + rounds: row?.rounds ?? 0, + maxGoalRounds: row?.max_goal_rounds ?? DefaultMaxGoalRounds, + armed: yield* activation.isArmed(sessionID), + todo: yield* readTodo(db, sessionID), + } + }) + + // The CAS reads the goal and publishes its successor in two steps. The + // per-session lock keeps a concurrent mutation from slipping between them, so + // two updates carrying the same token cannot both pass. + const locks = KeyedMutex.makeUnsafe() + + const update = Effect.fn("SessionGoal.update")(function* (sessionID: SessionSchema.ID, input: UpdateInput) { + const current = yield* read(db, sessionID) + const expected = + input.expectedRevision === undefined + ? undefined + : { revision: input.expectedRevision, goalID: input.expectedGoalID } + if (expected && stale(current, expected)) + return yield* new ConflictError({ + message: `Goal changed: expected ${expected.goalID ?? "none"}@${expected.revision}, found ${current?.id ?? "none"}@${current?.revision ?? "none"}`, + }) + if (input.action === "set") { + const snapshot: SessionGoalSchema.Snapshot = { + id: SessionGoalSchema.ID.create(), + revision: 1, + objective: yield* requireObjective(input), + phase: "active", + maxGoalRounds: DefaultMaxGoalRounds, + } + yield* publishUpdated(bus, sessionID, snapshot) + yield* activation.arm(sessionID) + return snapshot + } + const goal = yield* requireGoal(current) + if (input.action === "edit") { + yield* requirePhase(goal, input.action, ["active", "paused"]) + const snapshot = { ...goal, revision: goal.revision + 1, objective: yield* requireObjective(input) } + yield* publishUpdated(bus, sessionID, snapshot) + return snapshot + } + if (input.action === "pause") { + yield* requirePhase(goal, input.action, ["active"]) + // Disarm first: it is the only action that stops the goal immediately, and + // it commits separately from the durable phase. Then cancel the running turn, + // but only a goal this process was actually driving: the phase guard already + // guarantees `active`, so `armed` is the remaining signal. + const armed = yield* activation.isArmed(sessionID) + yield* activation.disarm(sessionID) + const snapshot = { ...goal, revision: goal.revision + 1, phase: "paused" as const } + yield* publishUpdated(bus, sessionID, snapshot) + if (armed) yield* Effect.uninterruptible(execution.interrupt(sessionID)) + return snapshot + } + if (input.action === "resume") { + yield* requirePhase(goal, input.action, ["paused"]) + const snapshot = { ...goal, revision: goal.revision + 1, phase: "active" as const } + yield* publishUpdated(bus, sessionID, snapshot) + yield* activation.arm(sessionID) + return snapshot + } + yield* activation.disarm(sessionID) + yield* bus.publish(SessionEvent.Goal.Cleared, { + sessionID, + goalID: goal.id, + revision: goal.revision + 1, + }) + return { ...goal, revision: goal.revision + 1 } + }) + + const replaceTodo = Effect.fn("SessionGoal.replaceTodo")(function* ( + sessionID: SessionSchema.ID, + todos: ReadonlyArray, + expected?: Expected, + ) { + const goal = yield* requireGoal(yield* read(db, sessionID)) + if (expected && stale(goal, expected)) + return yield* new ConflictError({ + message: `Goal changed: expected ${expected.goalID ?? "none"}@${expected.revision}, found ${goal.id}@${goal.revision}`, + }) + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: goal.revision + 1, todos }) + }) + + return Service.of({ + read: (sessionID) => read(db, sessionID), + readTodo: (sessionID) => readTodo(db, sessionID), + readState, + update: (sessionID, input) => locks.withLock(sessionID)(update(sessionID, input)), + replaceTodo: (sessionID, todos, expected) => locks.withLock(sessionID)(replaceTodo(sessionID, todos, expected)), + }) + }), +) + +export const node = makeGlobalNode({ + service: Service, + layer, + deps: [Database.node, Bus.node, SessionExecution.node, SessionGoalActivation.node], +}) + +function requireGoal(goal: SessionGoalSchema.Snapshot | undefined) { + return goal ? Effect.succeed(goal) : Effect.fail(new ConflictError({ message: "No goal is set for this session" })) +} + +/** True when the client's CAS token no longer matches the durable goal. */ +function stale(goal: SessionGoalSchema.Snapshot | undefined, expected: Expected) { + return goal?.revision !== expected.revision || goal?.id !== expected.goalID +} + +/** Rejects an action the goal's durable phase does not allow. */ +function requirePhase( + goal: SessionGoalSchema.Snapshot, + action: UpdateInput["action"], + allowed: ReadonlyArray, +) { + return allowed.includes(goal.phase) + ? Effect.void + : Effect.fail(new ConflictError({ message: `Goal action "${action}" is not allowed in phase "${goal.phase}"` })) +} + +function requireObjective(input: UpdateInput) { + return input.objective !== undefined && input.objective.length > 0 + ? Effect.succeed(input.objective) + : Effect.fail(new ConflictError({ message: `Goal action "${input.action}" requires an objective` })) +} + +function publishUpdated(bus: Bus.Interface, sessionID: SessionSchema.ID, snapshot: SessionGoalSchema.Snapshot) { + return bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot }) +} diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 24d9ce352809..b53e21b9f742 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -161,6 +161,10 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { }), ) }, + "session.goal.updated": () => Effect.void, + "session.goal.cleared": () => Effect.void, + "session.todo.updated": () => Effect.void, + "session.todo.verified": () => Effect.void, "session.synthetic": (event) => { return adapter.appendMessage( SessionMessage.Synthetic.make({ diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 7cce5b808a9b..86af7f1b825c 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -13,6 +13,7 @@ import { SessionMessageUpdater } from "./message-updater.js" import { SessionInbox } from "./inbox.js" import { InstructionState } from "./instruction-state.js" import { SessionBlob } from "./blob.js" +import { SessionGoal } from "./goal.js" import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js" import { SessionStored } from "./stored.js" import { InstructionEntry } from "./instruction-entry.js" @@ -734,6 +735,29 @@ const layer = Layer.effectDiscard( yield* InstructionState.apply(db, event.data.sessionID, event.durable.seq, event.data.delta) }), ) + yield* bus.project(SessionEvent.Goal.Updated, (event) => + SessionGoal.projectUpdated(db, { sessionID: event.data.sessionID, snapshot: event.data.snapshot }), + ) + yield* bus.project(SessionEvent.Goal.Cleared, (event) => + SessionGoal.projectCleared(db, { + sessionID: event.data.sessionID, + goalID: event.data.goalID, + }), + ) + yield* bus.project(SessionEvent.Todo.Updated, (event) => + SessionGoal.projectTodoUpdated(db, { + sessionID: event.data.sessionID, + revision: event.data.revision, + todos: event.data.todos, + }), + ) + yield* bus.project(SessionEvent.Todo.Verified, (event) => + SessionGoal.projectTodoVerified(db, { + sessionID: event.data.sessionID, + revision: event.data.revision, + items: event.data.items, + }), + ) yield* bus.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* bus.project(SessionEvent.Skill.Activated, (event) => run(db, event)) yield* bus.project(SessionEvent.Shell.Started, (event) => run(db, event)) diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index a4d0c3b351fa..61d41d04787f 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -14,6 +14,7 @@ import type { Workspace } from "@opencode/schema/workspace" import { Timestamps } from "../database/schema.sql.js" import type { Instruction } from "@opencode/schema/instruction" import type { Session } from "@opencode/schema/session" +import type { SessionGoal } from "@opencode/schema/session-goal" import type { CompactionPayload, MovePayload, SyntheticPayload } from "@opencode/schema/session-inbox" import type { RevertV1 } from "@opencode/schema/session-revert" import type { Schema } from "effect" @@ -188,3 +189,38 @@ export const BlobRefTable = sqliteTable( index("blob_ref_session_seq_idx").on(table.session_id, table.seq), ], ) + +/** One row per session: the current goal. Cleared goals leave no row; the event log is the audit trail. */ +export const SessionGoalTable = sqliteTable("session_goal", { + session_id: text() + .$type() + .primaryKey() + .references(() => SessionTable.id, { onDelete: "cascade" }), + goal_id: text().$type().notNull(), + revision: integer().notNull(), + objective: text().notNull(), + phase: text().$type().notNull(), + blocked_reason: text({ mode: "json" }).$type(), + max_goal_rounds: integer().notNull().default(8), + rounds: integer().notNull().default(0), + ...Timestamps, +}) + +/** Whole-list replacement keyed by array index; there is no per-item identity. */ +export const SessionTodoTable = sqliteTable( + "session_todo", + { + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + position: integer().notNull(), + content: text().notNull(), + status: text().$type().notNull(), + acceptance: text(), + feedback: text(), + ...Timestamps, + }, + (table) => [primaryKey({ columns: [table.session_id, table.position] })], +) + diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index 3044a82e6fee..ed58a3a7afe2 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -6,8 +6,10 @@ import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" import { Location } from "@opencode/core/location" import { Mcp } from "@opencode/core/mcp/index" import { CommandPlugin } from "@opencode/core/plugin/command" +import { SessionGoal } from "@opencode/core/session/goal" import { AbsolutePath } from "@opencode/core/schema" import { Session } from "@opencode/schema/session" +import { SessionGoal as GoalSchema } from "@opencode/schema/session-goal" import { SessionInbox } from "@opencode/schema/session-inbox" import { SessionMessage } from "@opencode/schema/session-message" import { LayerNode } from "@opencode/util/effect/layer-node" @@ -25,10 +27,34 @@ const locationLayer = Layer.succeed( Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project })), ) +// The plugin only needs the goal service to register the command; the stub records +// the parsed input so the `/goal` grammar is observable. +const goalUpdates: SessionGoal.UpdateInput[] = [] +const goalLayer = Layer.succeed( + SessionGoal.Service, + SessionGoal.Service.of({ + read: () => Effect.succeed(undefined), + readTodo: () => Effect.succeed([]), + readState: () => Effect.die("unused SessionGoal.readState"), + update: (_sessionID, input) => + Effect.sync(() => { + goalUpdates.push(input) + return { + id: GoalSchema.ID.make("goal_test"), + revision: 1, + objective: "ship it", + phase: "active" as const, + maxGoalRounds: 8, + } + }), + replaceTodo: () => Effect.die("unused SessionGoal.replaceTodo"), + }), +) const it = testEffect( - AppNodeBuilder.build(LayerNode.group([Command.node, Mcp.node, Bus.node]), [ + AppNodeBuilder.build(LayerNode.group([Command.node, Mcp.node, Bus.node, SessionGoal.node]), [ Mcp.node.replace(emptyMcpLayer), Location.node.replace(locationLayer), + SessionGoal.node.replace(goalLayer), ]), ) @@ -134,4 +160,48 @@ describe("CommandPlugin.Plugin", () => { ]) }), ) + + it.effect("parses /goal subcommands exactly", () => + Effect.gen(function* () { + const command = yield* Command.Service + yield* CommandPlugin.Plugin.effect( + host({ + command: { + list: () => Effect.die("unused command.list"), + transform: command.transform, + reload: command.reload, + }, + }), + ).pipe( + Effect.provideService( + Location.Service, + Location.Service.of(location({ directory }, { projectDirectory: project })), + ), + ) + + goalUpdates.length = 0 + const run = (text: string) => + command.execute({ + name: "goal", + invocation: { sessionID: Session.ID.make("ses_test"), prompt: { text }, delivery: "steer" }, + }) + yield* run("clear") + yield* run("pause") + yield* run("resume") + yield* run("edit do the thing") + yield* run("clear the flaky tests") + yield* run("pause the cron job before deploy") + yield* run("ship it") + + expect(goalUpdates).toEqual([ + { action: "clear" }, + { action: "pause" }, + { action: "resume" }, + { action: "edit", objective: "do the thing" }, + { action: "set", objective: "clear the flaky tests" }, + { action: "set", objective: "pause the cron job before deploy" }, + { action: "set", objective: "ship it" }, + ]) + }), + ) }) diff --git a/packages/core/test/session-goal.test.ts b/packages/core/test/session-goal.test.ts new file mode 100644 index 000000000000..545f895add23 --- /dev/null +++ b/packages/core/test/session-goal.test.ts @@ -0,0 +1,428 @@ +import { describe, expect } from "bun:test" +import { eq, sql } from "drizzle-orm" +import { Effect, Layer } from "effect" +import { Database } from "@opencode/core/database/database" +import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" +import { LayerNode } from "@opencode/util/effect/layer-node" +import { Bus } from "@opencode/core/bus" +import { Project } from "@opencode/core/project" +import { ProjectTable } from "@opencode/core/project/sql" +import { AbsolutePath } from "@opencode/core/schema" +import { SessionEvent } from "@opencode/core/session/event" +import { SessionExecution } from "@opencode/core/session/execution" +import { SessionGoal } from "@opencode/core/session/goal" +import { SessionGoalActivation } from "@opencode/core/session/goal-activation" +import { SessionProjector } from "@opencode/core/session/projector" +import { SessionSchema } from "@opencode/core/session/schema" +import { SessionGoalTable, SessionTable } from "@opencode/core/session/sql" +import { SessionGoal as GoalSchema } from "@opencode/schema/session-goal" +import { testEffect } from "./lib/effect" + +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [ + Bus.node.replace(Bus.configured({ persist: true })), + ]), +) + +const sessionID = SessionSchema.ID.make("ses_goal_test") + +const setup = Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "goal-test", + directory: "/project", + title: "Goal test", + version: "test", + }) + .run() + .pipe(Effect.orDie) + return { db, bus: yield* Bus.Service } +}) + +const snapshot = (revision: number, overrides?: Partial): GoalSchema.Snapshot => ({ + id: GoalSchema.ID.make("goal_test"), + revision, + objective: "ship it", + phase: "active", + maxGoalRounds: 8, + ...overrides, +}) + +const todos = [ + { content: "first", status: "pending" as const, acceptance: "tests pass" }, + { content: "second", status: "in_progress" as const }, +] + +const rounds = (db: Effect.Success["db"]) => + db + .select({ rounds: SessionGoalTable.rounds }) + .from(SessionGoalTable) + .where(eq(SessionGoalTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) + +describe("SessionGoal", () => { + it.effect("migration creates the session_goal and session_todo tables", () => + Effect.gen(function* () { + const { db } = yield* setup + const tables = yield* db + .all<{ name: string }>( + sql`select name from sqlite_master where type = 'table' and name in ('session_goal', 'session_todo')`, + ) + .pipe(Effect.orDie) + expect(tables.map((row) => row.name).toSorted()).toEqual(["session_goal", "session_todo"]) + }), + ) + + it.effect("goal.updated upserts and the latest event wins", () => + Effect.gen(function* () { + const { db, bus } = yield* setup + yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(1) }) + expect(yield* SessionGoal.read(db, sessionID)).toMatchObject({ revision: 1, objective: "ship it" }) + + // A newer revision wins. + yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(2, { phase: "paused" }) }) + expect(yield* SessionGoal.read(db, sessionID)).toMatchObject({ revision: 2, phase: "paused" }) + }), + ) + + it.effect("goal.updated replaces the goal when the id differs, even at a lower revision", () => + Effect.gen(function* () { + const { db, bus } = yield* setup + yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(5) }) + yield* bus.publish(SessionEvent.Goal.Updated, { + sessionID, + snapshot: snapshot(1, { id: GoalSchema.ID.make("goal_other"), objective: "new goal" }), + }) + expect(yield* SessionGoal.read(db, sessionID)).toMatchObject({ + id: "goal_other", + revision: 1, + objective: "new goal", + }) + }), + ) + + it.effect("goal.cleared removes the row and its todo list", () => + Effect.gen(function* () { + const { db, bus } = yield* setup + yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(1) }) + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 2, todos }) + expect(yield* SessionGoal.readTodo(db, sessionID)).toHaveLength(2) + + yield* bus.publish(SessionEvent.Goal.Cleared, { + sessionID, + goalID: GoalSchema.ID.make("goal_test"), + revision: 3, + }) + expect(yield* SessionGoal.read(db, sessionID)).toBeUndefined() + expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([]) + }), + ) + + it.effect("a new goal identity clears the previous todo list", () => + Effect.gen(function* () { + const { db, bus } = yield* setup + yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(1) }) + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 2, todos }) + + yield* bus.publish(SessionEvent.Goal.Updated, { + sessionID, + snapshot: snapshot(3, { id: GoalSchema.ID.make("goal_other") }), + }) + expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([]) + }), + ) + + it.effect("todo.updated replaces the whole list and a replayed event is a no-op", () => + Effect.gen(function* () { + const { db, bus } = yield* setup + yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(1) }) + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 2, todos }) + expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([ + { position: 0, content: "first", status: "pending", acceptance: "tests pass", feedback: undefined }, + { position: 1, content: "second", status: "in_progress", acceptance: undefined, feedback: undefined }, + ]) + + // The same revision arrives twice; only the first one advances the goal. + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 2, todos: [todos[0]] }) + expect(yield* SessionGoal.readTodo(db, sessionID)).toHaveLength(2) + }), + ) + + it.effect("todo.updated never rewinds the goal revision, so a stale verdict stays rejected", () => + Effect.gen(function* () { + const { db, bus } = yield* setup + yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(1) }) + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 2, todos }) + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 3, todos }) + + // A late list event carrying an older revision must not move the goal back to a + // number a verdict judged against the previous list still carries. + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 2, todos: [todos[0]] }) + expect(yield* SessionGoal.read(db, sessionID)).toMatchObject({ revision: 3 }) + expect(yield* SessionGoal.readTodo(db, sessionID)).toHaveLength(2) + + yield* bus.publish(SessionEvent.Todo.Verified, { + sessionID, + revision: 2, + items: [{ position: 0, status: "completed", feedback: "stale" }], + }) + expect((yield* rounds(db))?.rounds).toBe(0) + expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([ + { position: 0, content: "first", status: "pending", acceptance: "tests pass", feedback: undefined }, + { position: 1, content: "second", status: "in_progress", acceptance: undefined, feedback: undefined }, + ]) + }), + ) + + it.effect("todo.updated without a goal row is dropped", () => + Effect.gen(function* () { + const { db, bus } = yield* setup + // A list only exists under a goal, so an event without one leaves no state. + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 1, todos }) + expect(yield* SessionGoal.read(db, sessionID)).toBeUndefined() + expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([]) + }), + ) + + it.effect("todo.verified applies a fresh verdict and advances rounds", () => + Effect.gen(function* () { + const { db, bus } = yield* setup + yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(1) }) + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 2, todos }) + yield* bus.publish(SessionEvent.Todo.Verified, { + sessionID, + revision: 2, + items: [ + { position: 0, status: "completed", feedback: "ok" }, + { position: 1, status: "in_progress", feedback: "needs work" }, + ], + }) + expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([ + { position: 0, content: "first", status: "completed", acceptance: "tests pass", feedback: "ok" }, + { position: 1, content: "second", status: "in_progress", acceptance: undefined, feedback: "needs work" }, + ]) + expect((yield* rounds(db))?.rounds).toBe(1) + }), + ) + + it.effect("todo.verified rejects a verdict judged against a stale list", () => + Effect.gen(function* () { + const { db, bus } = yield* setup + yield* bus.publish(SessionEvent.Goal.Updated, { sessionID, snapshot: snapshot(1) }) + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 2, todos }) + // The list is replaced after the verifier read it. + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 3, todos }) + + yield* bus.publish(SessionEvent.Todo.Verified, { + sessionID, + revision: 2, + items: [{ position: 0, status: "completed", feedback: "stale" }], + }) + expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([ + { position: 0, content: "first", status: "pending", acceptance: "tests pass", feedback: undefined }, + { position: 1, content: "second", status: "in_progress", acceptance: undefined, feedback: undefined }, + ]) + expect((yield* rounds(db))?.rounds).toBe(0) + }), + ) + + it.effect("todo.verified with no goal row is a no-op", () => + Effect.gen(function* () { + const { db, bus } = yield* setup + yield* bus.publish(SessionEvent.Todo.Updated, { sessionID, revision: 1, todos }) + yield* bus.publish(SessionEvent.Todo.Verified, { + sessionID, + revision: 1, + items: [{ position: 0, status: "completed", feedback: "ok" }], + }) + expect(yield* SessionGoal.read(db, sessionID)).toBeUndefined() + expect(yield* SessionGoal.readTodo(db, sessionID)).toEqual([]) + }), + ) +}) + +const serviceIt = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionGoal.node, SessionGoalActivation.node]), + [Bus.node.replace(Bus.configured({ persist: true })), SessionExecution.node.replace(SessionExecution.noopLayer)], + ), +) + +// Records the interrupts `pause` issues, so the gate on the process-local arming is observable. +const interrupts: SessionSchema.ID[] = [] +const recordingExecution = Layer.succeed( + SessionExecution.Service, + SessionExecution.Service.of({ + active: Effect.succeed(new Set()), + isActive: () => Effect.succeed(false), + resume: () => Effect.void, + wake: () => Effect.void, + interrupt: (sessionID) => + Effect.sync(() => { + interrupts.push(sessionID) + return true + }), + awaitIdle: () => Effect.void, + }), +) + +const interruptIt = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionGoal.node, SessionGoalActivation.node]), + [Bus.node.replace(Bus.configured({ persist: true })), SessionExecution.node.replace(recordingExecution)], + ), +) + +describe("SessionGoal.Service", () => { + serviceIt.effect("set arms the goal, pause disarms it, resume re-arms it, and clear disarms it", () => + Effect.gen(function* () { + yield* setup + const goal = yield* SessionGoal.Service + const activation = yield* SessionGoalActivation.Service + + expect(yield* goal.update(sessionID, { action: "set", objective: "ship it" })).toMatchObject({ + revision: 1, + phase: "active", + }) + expect(yield* activation.isArmed(sessionID)).toBe(true) + + // `pause` is the user's cancel: it disarms the goal in this process. + expect(yield* goal.update(sessionID, { action: "pause" })).toMatchObject({ revision: 2, phase: "paused" }) + expect(yield* activation.isArmed(sessionID)).toBe(false) + + expect(yield* goal.update(sessionID, { action: "resume" })).toMatchObject({ revision: 3, phase: "active" }) + expect(yield* activation.isArmed(sessionID)).toBe(true) + + yield* goal.update(sessionID, { action: "clear" }) + expect(yield* activation.isArmed(sessionID)).toBe(false) + expect(yield* goal.read(sessionID)).toBeUndefined() + }), + ) + + serviceIt.effect("readState reports the goal, its todo list, and process-local arming", () => + Effect.gen(function* () { + yield* setup + const goal = yield* SessionGoal.Service + yield* goal.update(sessionID, { action: "set", objective: "ship it" }) + yield* goal.replaceTodo(sessionID, [{ content: "first", status: "pending", acceptance: "tests pass" }]) + + expect(yield* goal.readState(sessionID)).toMatchObject({ + goal: { revision: 2, objective: "ship it" }, + rounds: 0, + maxGoalRounds: 8, + armed: true, + todo: [{ position: 0, content: "first", status: "pending", acceptance: "tests pass" }], + }) + }), + ) + + serviceIt.effect("edit without a goal and a stale expectedRevision are conflicts", () => + Effect.gen(function* () { + yield* setup + const goal = yield* SessionGoal.Service + const conflict = (input: SessionGoal.UpdateInput) => goal.update(sessionID, input).pipe(Effect.flip) + + expect((yield* conflict({ action: "edit", objective: "x" }))._tag).toBe("SessionGoal.ConflictError") + yield* goal.update(sessionID, { action: "set", objective: "ship it" }) + expect((yield* conflict({ action: "edit", objective: "x", expectedRevision: 99 }))._tag).toBe( + "SessionGoal.ConflictError", + ) + }), + ) + + serviceIt.effect("a CAS token from a previous goal is rejected even at the same revision", () => + Effect.gen(function* () { + yield* setup + const goal = yield* SessionGoal.Service + const first = yield* goal.update(sessionID, { action: "set", objective: "first" }) + const second = yield* goal.update(sessionID, { action: "set", objective: "second" }) + // `set` starts the new goal at revision 1, so the revision alone cannot tell + // the two goals apart. + expect(second).toMatchObject({ revision: 1, objective: "second" }) + expect(second.id).not.toBe(first.id) + + const conflict = yield* goal + .update(sessionID, { action: "edit", objective: "x", expectedRevision: 1, expectedGoalID: first.id }) + .pipe(Effect.flip) + expect(conflict._tag).toBe("SessionGoal.ConflictError") + expect(yield* goal.read(sessionID)).toMatchObject({ objective: "second" }) + + // The current goal's own token still passes. + expect( + yield* goal.update(sessionID, { action: "edit", objective: "x", expectedRevision: 1, expectedGoalID: second.id }), + ).toMatchObject({ revision: 2, objective: "x" }) + }), + ) + + serviceIt.effect("phase guards reject pause on paused, resume on active, and edit/resume on a terminal goal", () => + Effect.gen(function* () { + const { bus } = yield* setup + const goal = yield* SessionGoal.Service + const conflict = (input: SessionGoal.UpdateInput) => goal.update(sessionID, input).pipe(Effect.flip) + + const active = yield* goal.update(sessionID, { action: "set", objective: "ship it" }) + expect((yield* conflict({ action: "resume" }))._tag).toBe("SessionGoal.ConflictError") + + yield* goal.update(sessionID, { action: "pause" }) + expect((yield* conflict({ action: "pause" }))._tag).toBe("SessionGoal.ConflictError") + + const resumed = yield* goal.update(sessionID, { action: "resume" }) + expect(resumed).toMatchObject({ phase: "active" }) + + // A terminal phase is written by the driver; the service must still refuse to move it. + yield* bus.publish(SessionEvent.Goal.Updated, { + sessionID, + snapshot: { ...active, revision: resumed.revision + 1, phase: "complete" }, + }) + expect((yield* conflict({ action: "edit", objective: "x" }))._tag).toBe("SessionGoal.ConflictError") + expect((yield* conflict({ action: "resume" }))._tag).toBe("SessionGoal.ConflictError") + }), + ) + + serviceIt.effect("replaceTodo rejects a stale CAS token", () => + Effect.gen(function* () { + yield* setup + const goal = yield* SessionGoal.Service + const first = yield* goal.update(sessionID, { action: "set", objective: "ship it" }) + yield* goal.replaceTodo(sessionID, todos, { revision: 1, goalID: first.id }) + expect(yield* goal.readTodo(sessionID)).toHaveLength(2) + + const conflict = yield* goal + .replaceTodo(sessionID, [], { revision: 1, goalID: first.id }) + .pipe(Effect.flip) + expect(conflict._tag).toBe("SessionGoal.ConflictError") + expect(yield* goal.readTodo(sessionID)).toHaveLength(2) + }), + ) + + interruptIt.effect("pause interrupts only a goal this process armed", () => + Effect.gen(function* () { + yield* setup + const goal = yield* SessionGoal.Service + const activation = yield* SessionGoalActivation.Service + + interrupts.length = 0 + yield* goal.update(sessionID, { action: "set", objective: "ship it" }) + yield* goal.update(sessionID, { action: "pause" }) + expect(interrupts).toEqual([sessionID]) + + // Active but not armed in this process (a restart leaves it disarmed): nothing to cancel. + yield* goal.update(sessionID, { action: "resume" }) + yield* activation.disarm(sessionID) + interrupts.length = 0 + yield* goal.update(sessionID, { action: "pause" }) + expect(interrupts).toEqual([]) + }), + ) +}) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 47ac7cd47248..3e6537c78e92 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -2,6 +2,7 @@ import { SessionMessage } from "@opencode/schema/session-message" import { SessionInbox } from "@opencode/schema/session-inbox" import { PromptInput } from "@opencode/schema/prompt-input" import { Session } from "@opencode/schema/session" +import { SessionGoal } from "@opencode/schema/session-goal" import { SessionStats } from "@opencode/schema/session-stats" import { InstructionEntry } from "@opencode/schema/instruction-entry" import { Project } from "@opencode/schema/project" @@ -705,6 +706,65 @@ export const makeSessionGroup = ({ create: () => schema.make("goal_" + descending()) })), +) +export type ID = typeof ID.Type + +export const Phase = Schema.Literals(["active", "paused", "blocked", "complete"]) +export type Phase = typeof Phase.Type + +export interface Reason extends Schema.Schema.Type {} +export const Reason = Schema.Struct({ + code: Schema.String, + message: Schema.String, +}).annotate({ identifier: "SessionGoal.Reason" }) + +export interface Snapshot extends Schema.Schema.Type {} +export const Snapshot = Schema.Struct({ + id: ID, + /** CAS reference; every durable mutation increments it. */ + revision: Schema.Number, + objective: Schema.String, + phase: Phase, + blockedReason: optional(Reason), + maxGoalRounds: Schema.Number, +}).annotate({ identifier: "SessionGoal.Snapshot" }) + +export const TodoStatus = Schema.Literals(["pending", "in_progress", "completed", "cancelled"]) +export type TodoStatus = typeof TodoStatus.Type + +export interface TodoItem extends Schema.Schema.Type {} +export const TodoItem = Schema.Struct({ + position: Schema.Number, + content: Schema.String, + status: TodoStatus, + /** Verifier-owned: how this item is judged done. */ + acceptance: optional(Schema.String), + /** Verifier-owned: why the last verification did not pass this item. */ + feedback: optional(Schema.String), +}).annotate({ identifier: "SessionGoal.TodoItem" }) + +/** The model-authored subset of a todo item; position is the array index. */ +export interface TodoInput extends Schema.Schema.Type {} +export const TodoInput = Schema.Struct({ + content: Schema.String, + status: TodoStatus, + acceptance: optional(Schema.String), +}).annotate({ identifier: "SessionGoal.TodoInput" }) + +/** One verifier verdict per todo position; feedback is verifier-owned. */ +export interface TodoVerification extends Schema.Schema.Type {} +export const TodoVerification = Schema.Struct({ + position: Schema.Number, + status: TodoStatus, + feedback: Schema.String, +}).annotate({ identifier: "SessionGoal.TodoVerification" }) + +/** + * The goal read model. `createdAt` is the durable row's creation time (the only + * source of elapsed time), and `armed` is process-local truth: whether this + * OpenCode process is currently willing to run the goal. + */ +export interface State extends Schema.Schema.Type {} +export const State = Schema.Struct({ + goal: optional(Snapshot), + createdAt: optional(Schema.Number), + rounds: Schema.Number, + maxGoalRounds: Schema.Number, + armed: Schema.Boolean, + todo: Schema.Array(TodoItem), +}).annotate({ identifier: "SessionGoal.State" }) diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index d7c07244eda2..e2ef6e7e1640 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -125,6 +125,10 @@ describe("public event manifest", () => { "session.execution.failed.1", "session.execution.interrupted.1", "session.instructions.updated.2", + "session.goal.updated.1", + "session.goal.cleared.1", + "session.todo.updated.1", + "session.todo.verified.1", "session.synthetic.1", "session.skill.activated.1", "session.shell.started.1", diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 2b7f8b990337..4bcaf7488627 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -1,5 +1,6 @@ import { Instance } from "@opencode/core/instance/service" import { Session } from "@opencode/core/session" +import { SessionGoal } from "@opencode/core/session/goal" import { SessionStats } from "@opencode/core/session/stats" import { SessionTitle } from "@opencode/core/session/title" import { SessionTransfer } from "@opencode/core/session/transfer" @@ -618,6 +619,48 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl return HttpApiSchema.NoContent.make() }), ) + .handle( + "session.goal.get", + Effect.fn(function* (ctx) { + yield* session.get(ctx.params.sessionID).pipe(Effect.catchTag("Session.NotFoundError", missingSession)) + const goal = yield* SessionGoal.Service + return { data: yield* goal.readState(ctx.params.sessionID) } + }), + ) + .handle( + "session.goal.update", + Effect.fn(function* (ctx) { + yield* session.get(ctx.params.sessionID).pipe(Effect.catchTag("Session.NotFoundError", missingSession)) + const goal = yield* SessionGoal.Service + return { + data: yield* goal + .update(ctx.params.sessionID, ctx.payload) + .pipe( + Effect.catchTag( + "SessionGoal.ConflictError", + (error) => new ConflictError({ message: error.message }), + ), + ), + } + }), + ) + .handle( + "session.todo.replace", + Effect.fn(function* (ctx) { + yield* session.get(ctx.params.sessionID).pipe(Effect.catchTag("Session.NotFoundError", missingSession)) + const goal = yield* SessionGoal.Service + const expected = + ctx.payload.expectedRevision === undefined + ? undefined + : { revision: ctx.payload.expectedRevision, goalID: ctx.payload.expectedGoalID } + yield* goal + .replaceTodo(ctx.params.sessionID, ctx.payload.todos, expected) + .pipe( + Effect.catchTag("SessionGoal.ConflictError", (error) => new ConflictError({ message: error.message })), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.message", Effect.fn(function* (ctx) { diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 19122cfe789f..c8c94f917329 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -16,6 +16,8 @@ import { PersistentPty } from "@opencode/core/persistent-pty" import { Project } from "@opencode/core/project" import { Worktree } from "@opencode/core/worktree" import { Session } from "@opencode/core/session" +import { SessionGoal } from "@opencode/core/session/goal" +import { SessionGoalActivation } from "@opencode/core/session/goal-activation" import { Instance } from "@opencode/core/instance/service" import { SessionTransfer } from "@opencode/core/session/transfer" import { InstructionEntry } from "@opencode/core/session/instruction-entry" @@ -63,6 +65,8 @@ const applicationServiceNodes = [ Project.sweepNode, Worktree.node, Session.node, + SessionGoal.node, + SessionGoalActivation.node, Instance.node, SessionTransfer.node, InstructionEntry.node, @@ -176,6 +180,7 @@ function makeRoutes( PluginUpdate.Service, Project.Service, WellKnown.Service, + SessionGoal.Service, )(context), ), ServerInfo.layer(serviceURLs, Context.get(context, Global.Service).tmp, options.app), diff --git a/packages/server/test/session-goal.test.ts b/packages/server/test/session-goal.test.ts new file mode 100644 index 000000000000..fb6ec5e0762a --- /dev/null +++ b/packages/server/test/session-goal.test.ts @@ -0,0 +1,111 @@ +import { expect } from "bun:test" +import { Session } from "@opencode/schema/session" +import { SessionGoal } from "@opencode/schema/session-goal" +import { Effect, Schema } from "effect" +import { it } from "../../core/test/lib/effect" +import { ServerFetch } from "../src/fetch" + +const SessionResponse = Schema.Struct({ data: Schema.toEncoded(Session.Info) }) +const GoalState = Schema.Struct({ data: SessionGoal.State }) +const GoalSnapshot = Schema.Struct({ data: SessionGoal.Snapshot }) + +it.live("serves the session goal and todo endpoints", () => + Effect.gen(function* () { + const handler = yield* ServerFetch.make({ + app: { version: "test" }, + database: { path: ":memory:" }, + fs: { filewatcher: false }, + models: { fetch: false }, + }) + const request = (path: string, method: string, body?: unknown) => + Effect.promise(async () => { + const response = await handler( + new Request(`http://opencode.local${path}`, { + method, + headers: { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + ) + return { status: response.status, body: response.status === 204 ? undefined : await response.json() } + }) + + const created = Schema.decodeUnknownSync(SessionResponse)( + (yield* request("/api/session", "POST", { title: "Goal" })).body, + ) + const goal = `/api/session/${created.data.id}/goal` + const todo = `/api/session/${created.data.id}/todo` + + // A fresh session has no goal, is disarmed, and has an empty todo list. + const empty = Schema.decodeUnknownSync(GoalState)((yield* request(goal, "GET")).body) + expect(empty.data).toMatchObject({ rounds: 0, maxGoalRounds: 8, armed: false, todo: [] }) + expect(empty.data.goal).toBeUndefined() + + // `set` creates the goal and arms it in this process. + const set = Schema.decodeUnknownSync(GoalSnapshot)( + (yield* request(goal, "POST", { action: "set", objective: "ship it" })).body, + ) + expect(set.data).toMatchObject({ revision: 1, objective: "ship it", phase: "active" }) + expect(Schema.decodeUnknownSync(GoalState)((yield* request(goal, "GET")).body).data.armed).toBe(true) + + // `resume` on an active goal is a phase conflict. + expect((yield* request(goal, "POST", { action: "resume" })).status).toBe(409) + + // The todo list is replaced wholesale. + expect( + (yield* request(todo, "PUT", { todos: [{ content: "first", status: "pending", acceptance: "tests pass" }] })) + .status, + ).toBe(204) + expect(Schema.decodeUnknownSync(GoalState)((yield* request(goal, "GET")).body).data.todo).toEqual([ + { position: 0, content: "first", status: "pending", acceptance: "tests pass" }, + ]) + + // A stale CAS token on the todo path is a conflict. + expect( + (yield* request(todo, "PUT", { todos: [], expectedRevision: 1, expectedGoalID: set.data.id })).status, + ).toBe(409) + + // `pause` is the user's cancel: it disarms and bumps the revision. + const paused = Schema.decodeUnknownSync(GoalSnapshot)((yield* request(goal, "POST", { action: "pause" })).body) + expect(paused.data).toMatchObject({ revision: 3, phase: "paused" }) + expect(Schema.decodeUnknownSync(GoalState)((yield* request(goal, "GET")).body).data.armed).toBe(false) + + // `pause` on a paused goal is a phase conflict. + expect((yield* request(goal, "POST", { action: "pause" })).status).toBe(409) + + // `resume` re-arms. + const resumed = Schema.decodeUnknownSync(GoalSnapshot)((yield* request(goal, "POST", { action: "resume" })).body) + expect(resumed.data).toMatchObject({ revision: 4, phase: "active" }) + expect(Schema.decodeUnknownSync(GoalState)((yield* request(goal, "GET")).body).data.armed).toBe(true) + + // `clear` removes the goal and its todo list. + yield* request(goal, "POST", { action: "clear" }) + const cleared = Schema.decodeUnknownSync(GoalState)((yield* request(goal, "GET")).body) + expect(cleared.data).toMatchObject({ armed: false, todo: [] }) + expect(cleared.data.goal).toBeUndefined() + + // `edit` without a goal is a conflict, and so is a stale `expectedRevision`. + expect((yield* request(goal, "POST", { action: "edit", objective: "x" })).status).toBe(409) + yield* request(goal, "POST", { action: "set", objective: "ship it" }) + expect((yield* request(goal, "POST", { action: "edit", objective: "x", expectedRevision: 99 })).status).toBe(409) + + // A CAS token from the previous goal is rejected even at the same revision. + const first = Schema.decodeUnknownSync(GoalSnapshot)( + (yield* request(goal, "POST", { action: "set", objective: "first" })).body, + ) + const second = Schema.decodeUnknownSync(GoalSnapshot)( + (yield* request(goal, "POST", { action: "set", objective: "second" })).body, + ) + expect(second.data.revision).toBe(1) + expect( + (yield* request(goal, "POST", { + action: "edit", + objective: "x", + expectedRevision: 1, + expectedGoalID: first.data.id, + })).status, + ).toBe(409) + + // An unknown session is not found. + expect((yield* request(`/api/session/${Session.ID.create()}/goal`, "GET")).status).toBe(404) + }).pipe(Effect.scoped), +)