From e79b53846170bf0a5e9ba19b2cd93bc1b4986b7d Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 24 Jul 2026 01:39:02 +0800 Subject: [PATCH] feat(core): add Goal v3 state protocol (#7517) * feat(core): add Goal v3 state protocol * fix(core): address Goal protocol review feedback * fix(core): recover paused legacy Goals * fix(core): reject unsupported Goal controls * fix(core): cover recoverable Goal states * fix(core): recover older valid Goal state Continue past malformed lifecycle records so a newer corrupt write cannot hide the latest valid Goal snapshot. --------- Co-authored-by: Shaojin Wen --- packages/core/package.json | 4 + .../src/goals/goal-legacy-projection.test.ts | 125 +++++ .../core/src/goals/goal-legacy-projection.ts | 137 +++++ .../core/src/goals/goal-persistence.test.ts | 249 +++++++++ packages/core/src/goals/goal-persistence.ts | 145 +++++ packages/core/src/goals/goal-protocol.ts | 126 +++++ packages/core/src/goals/goal-reducer.test.ts | 505 ++++++++++++++++++ packages/core/src/goals/goal-reducer.ts | 485 +++++++++++++++++ packages/core/src/goals/goal-wire.ts | 34 ++ packages/core/src/goals/index.ts | 24 + 10 files changed, 1834 insertions(+) create mode 100644 packages/core/src/goals/goal-legacy-projection.test.ts create mode 100644 packages/core/src/goals/goal-legacy-projection.ts create mode 100644 packages/core/src/goals/goal-persistence.test.ts create mode 100644 packages/core/src/goals/goal-persistence.ts create mode 100644 packages/core/src/goals/goal-protocol.ts create mode 100644 packages/core/src/goals/goal-reducer.test.ts create mode 100644 packages/core/src/goals/goal-reducer.ts create mode 100644 packages/core/src/goals/goal-wire.ts diff --git a/packages/core/package.json b/packages/core/package.json index 188974ed5b..c198d4c1e4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,6 +17,10 @@ "types": "./dist/src/utils/transcript-records.d.ts", "import": "./dist/src/utils/transcript-records.js" }, + "./goalWire": { + "types": "./dist/src/goals/goal-wire.d.ts", + "import": "./dist/src/goals/goal-wire.js" + }, "./package.json": "./package.json", "./dist/*": "./dist/*", "./src/*": "./src/*" diff --git a/packages/core/src/goals/goal-legacy-projection.test.ts b/packages/core/src/goals/goal-legacy-projection.test.ts new file mode 100644 index 0000000000..163c2025c2 --- /dev/null +++ b/packages/core/src/goals/goal-legacy-projection.test.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import type { + GoalRecord, + GoalStateCause, + GoalStateRecordPayloadV2, +} from './goal-protocol.js'; +import { projectGoalStateToLegacy } from './goal-legacy-projection.js'; + +const GOAL: GoalRecord = { + goalId: 'goal-1', + revision: 2, + objective: 'ship it', + status: 'active', + evidenceCursor: { recordId: 'state-1' }, + turnCount: 4, + activeTimeMs: 2000, + createdAt: 100, + updatedAt: 200, + lastReason: 'continuing', +}; + +function payload( + cause: GoalStateCause, + status: GoalRecord['status'] = 'active', + goal: GoalRecord | null = { ...GOAL, status }, +): GoalStateRecordPayloadV2 { + return { + v: 2, + cause, + snapshot: { v: 2, activity: 'idle', goal }, + }; +} + +describe('projectGoalStateToLegacy', () => { + it.each(['create', 'replace', 'edit', 'resume', 'migrated'] as const)( + 'projects %s as legacy set with an active projection', + (cause) => { + const projected = projectGoalStateToLegacy(payload(cause)); + + expect(projected.goalStatus.kind).toBe('set'); + expect(projected.activeGoal).toMatchObject({ + condition: 'ship it', + iterations: 4, + setAt: 100, + lastReason: 'continuing', + }); + expect(projected.goalTerminal).toBeNull(); + }, + ); + + it('projects completion as achieved and stops active_goal', () => { + const projected = projectGoalStateToLegacy(payload('complete', 'complete')); + + expect(projected.goalStatus.kind).toBe('achieved'); + expect(projected.activeGoal).toBeNull(); + expect(projected.goalTerminal).toMatchObject({ + kind: 'achieved', + condition: 'ship it', + iterations: 4, + durationMs: 2000, + }); + }); + + it('projects clear as cleared using the prior goal objective', () => { + const projected = projectGoalStateToLegacy( + payload('clear', 'active', null), + GOAL, + ); + + expect(projected.goalStatus).toMatchObject({ + kind: 'cleared', + condition: 'ship it', + }); + expect(projected.activeGoal).toBeNull(); + expect(projected.goalTerminal).toBeNull(); + }); + + it('projects pause as a non-terminal legacy paused state', () => { + const projected = projectGoalStateToLegacy(payload('pause', 'paused')); + + expect(projected.goalStatus.kind).toBe('paused'); + expect(projected.activeGoal).toBeNull(); + expect(projected.goalTerminal).toBeNull(); + }); + + it.each(['blocked', 'usage_limited'] as const)( + 'projects %s as a legacy stopped state', + (status) => { + const projected = projectGoalStateToLegacy(payload(status, status)); + + expect(projected.goalStatus.kind).toBe('aborted'); + expect(projected.activeGoal).toBeNull(); + expect(projected.goalTerminal).toMatchObject({ + kind: 'aborted', + condition: 'ship it', + }); + }, + ); + + it('uses checking for active runtime progress without widening the union', () => { + const projected = projectGoalStateToLegacy( + payload('turn_finished', 'active'), + ); + + expect(projected.goalStatus.kind).toBe('checking'); + expect(projected.activeGoal).not.toBeNull(); + expect(projected.goalTerminal).toBeNull(); + }); + + it('does not repeat an aborted terminal after a paused turn finishes', () => { + const projected = projectGoalStateToLegacy( + payload('turn_finished', 'paused'), + ); + + expect(projected.goalStatus.kind).toBe('checking'); + expect(projected.activeGoal).toBeNull(); + expect(projected.goalTerminal).toBeNull(); + }); +}); diff --git a/packages/core/src/goals/goal-legacy-projection.ts b/packages/core/src/goals/goal-legacy-projection.ts new file mode 100644 index 0000000000..45804c969b --- /dev/null +++ b/packages/core/src/goals/goal-legacy-projection.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { GoalRecord, GoalStateRecordPayloadV2 } from './goal-protocol.js'; + +export type LegacyGoalStatusKind = + | 'set' + | 'achieved' + | 'cleared' + | 'failed' + | 'aborted' + | 'paused' + | 'checking'; + +export interface LegacyGoalStatus { + type: 'goal_status'; + kind: LegacyGoalStatusKind; + condition: string; + iterations?: number; + setAt?: number; + durationMs?: number; + lastReason?: string; +} + +export interface LegacyActiveGoal { + readonly condition: string; + readonly iterations: number; + readonly setAt: number; + readonly tokensAtStart?: number; + readonly hookId?: string; + readonly lastReason?: string; +} + +export interface LegacyGoalTerminal { + kind: 'achieved' | 'failed' | 'aborted'; + condition: string; + iterations: number; + durationMs: number; + lastReason?: string; +} + +export interface LegacyGoalProjection { + activeGoal: LegacyActiveGoal | null; + goalStatus: LegacyGoalStatus; + goalTerminal: LegacyGoalTerminal | null; +} + +export function projectGoalStateToLegacy( + payload: GoalStateRecordPayloadV2, + previousGoal: GoalRecord | null = null, +): LegacyGoalProjection { + const snapshotGoal = payload.snapshot.goal; + const displayGoal = snapshotGoal ?? previousGoal; + const kind = legacyStatusKind(payload); + const goalStatus: LegacyGoalStatus = { + type: 'goal_status', + kind, + condition: displayGoal?.objective ?? '', + ...(displayGoal ? { iterations: displayGoal.turnCount } : {}), + ...(displayGoal ? { setAt: displayGoal.createdAt } : {}), + ...(displayGoal ? { durationMs: displayGoal.activeTimeMs } : {}), + ...(displayGoal?.lastReason === undefined + ? {} + : { lastReason: displayGoal.lastReason }), + }; + const terminalKind = + kind === 'achieved' || kind === 'failed' || kind === 'aborted' + ? kind + : undefined; + + return { + activeGoal: + snapshotGoal?.status === 'active' + ? { + condition: snapshotGoal.objective, + iterations: snapshotGoal.turnCount, + setAt: snapshotGoal.createdAt, + ...(snapshotGoal.lastReason === undefined + ? {} + : { lastReason: snapshotGoal.lastReason }), + } + : null, + goalStatus, + goalTerminal: + terminalKind && displayGoal + ? { + kind: terminalKind, + condition: displayGoal.objective, + iterations: displayGoal.turnCount, + durationMs: displayGoal.activeTimeMs, + ...(displayGoal.lastReason === undefined + ? {} + : { lastReason: displayGoal.lastReason }), + } + : null, + }; +} + +function legacyStatusKind( + payload: GoalStateRecordPayloadV2, +): LegacyGoalStatusKind { + switch (payload.cause) { + case 'create': + case 'replace': + case 'edit': + case 'resume': + case 'migrated': + return 'set'; + case 'complete': + return 'achieved'; + case 'clear': + return 'cleared'; + case 'pause': + return 'paused'; + case 'blocked': + case 'usage_limited': + return 'aborted'; + case 'turn_finished': + case 'verifier_accept': + case 'verifier_reject': + return payload.snapshot.goal?.status === 'complete' + ? 'achieved' + : payload.snapshot.goal?.status === 'blocked' || + payload.snapshot.goal?.status === 'usage_limited' + ? 'aborted' + : 'checking'; + default: + return assertNever(payload.cause); + } +} + +function assertNever(value: never): never { + throw new Error(`Unsupported Goal state cause: ${String(value)}`); +} diff --git a/packages/core/src/goals/goal-persistence.test.ts b/packages/core/src/goals/goal-persistence.test.ts new file mode 100644 index 0000000000..41a5858a40 --- /dev/null +++ b/packages/core/src/goals/goal-persistence.test.ts @@ -0,0 +1,249 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import type { GoalStateRecordPayloadV2 } from './goal-protocol.js'; +import type { GoalRecoveryRecord } from './goal-persistence.js'; +import { + createMigratedGoalState, + recoverGoalFromRecords, +} from './goal-persistence.js'; + +const ACTIVE_PAYLOAD: GoalStateRecordPayloadV2 = { + v: 2, + cause: 'create', + snapshot: { + v: 2, + activity: 'idle', + goal: { + goalId: 'goal-1', + revision: 1, + objective: 'ship it', + status: 'active', + evidenceCursor: { recordId: 'state-1' }, + turnCount: 3, + activeTimeMs: 1500, + createdAt: 100, + updatedAt: 200, + }, + }, +}; + +function record( + uuid: string, + overrides: Partial = {}, +): GoalRecoveryRecord { + return { + uuid, + type: 'system', + ...overrides, + }; +} + +describe('recoverGoalFromRecords', () => { + it('returns the newest valid v2 lifecycle snapshot', () => { + const newer = { + ...ACTIVE_PAYLOAD, + cause: 'pause' as const, + snapshot: { + ...ACTIVE_PAYLOAD.snapshot, + goal: { ...ACTIVE_PAYLOAD.snapshot.goal!, status: 'paused' as const }, + }, + }; + + expect( + recoverGoalFromRecords([ + record('state-1', { + subtype: 'goal_state', + systemPayload: ACTIVE_PAYLOAD, + }), + record('state-2', { + subtype: 'goal_state', + systemPayload: newer, + }), + ]), + ).toEqual({ kind: 'v2', payload: newer }); + }); + + it.each<{ + label: string; + overrides: Partial; + }>([ + { + label: 'malformed', + overrides: { + systemPayload: { + v: 3, + snapshot: ACTIVE_PAYLOAD.snapshot, + } as unknown as GoalStateRecordPayloadV2, + }, + }, + { + label: 'non-system', + overrides: { + type: 'user', + systemPayload: ACTIVE_PAYLOAD, + }, + }, + ])( + 'uses the newest valid lifecycle record when a newer record is $label', + ({ overrides }) => { + expect( + recoverGoalFromRecords([ + record('state-1', { + subtype: 'goal_state', + systemPayload: ACTIVE_PAYLOAD, + }), + record('state-2', { + subtype: 'goal_state', + ...overrides, + }), + ]), + ).toEqual({ kind: 'v2', payload: ACTIVE_PAYLOAD }); + }, + ); + + it('rejects a goal_state payload stored on a non-system record', () => { + expect( + recoverGoalFromRecords([ + record('state-1', { + type: 'user', + subtype: 'goal_state', + systemPayload: ACTIVE_PAYLOAD, + }), + ]), + ).toEqual({ + kind: 'unsupported', + reason: expect.stringContaining('state-1'), + }); + }); + + it.each(['paused', 'blocked', 'usage_limited', 'complete'] as const)( + 'restores %s state for display without making it active', + (status) => { + const payload: GoalStateRecordPayloadV2 = { + ...ACTIVE_PAYLOAD, + snapshot: { + ...ACTIVE_PAYLOAD.snapshot, + goal: { ...ACTIVE_PAYLOAD.snapshot.goal!, status }, + }, + }; + + const recovery = recoverGoalFromRecords([ + record('state-1', { + subtype: 'goal_state', + systemPayload: payload, + }), + ]); + + expect(recovery).toEqual({ kind: 'v2', payload }); + if (recovery.kind === 'v2') { + expect(recovery.payload.snapshot.activity).toBe('idle'); + expect(recovery.payload.snapshot.goal?.status).toBe(status); + } + }, + ); + + it('uses only the objective from a legacy active Goal', () => { + expect( + recoverGoalFromRecords([ + record('legacy', { + subtype: 'slash_command', + systemPayload: { + phase: 'result', + rawCommand: '/goal ship it', + outputHistoryItems: [ + { + type: 'goal_status', + kind: 'checking', + condition: 'ship it', + iterations: 19, + setAt: 42, + lastReason: 'old evidence', + }, + ], + }, + }), + ]), + ).toEqual({ kind: 'legacy', objective: 'ship it' }); + }); + + it('does not revive a stopped legacy Goal', () => { + expect( + recoverGoalFromRecords([ + record('legacy', { + subtype: 'slash_command', + systemPayload: { + phase: 'result', + rawCommand: '/goal', + outputHistoryItems: [ + { type: 'goal_status', kind: 'aborted', condition: 'ship it' }, + ], + }, + }), + ]), + ).toEqual({ kind: 'none' }); + }); + + it('does not revive a paused legacy Goal', () => { + expect( + recoverGoalFromRecords([ + record('legacy', { + subtype: 'slash_command', + systemPayload: { + phase: 'result', + rawCommand: '/goal', + outputHistoryItems: [ + { type: 'goal_status', kind: 'paused', condition: 'ship it' }, + ], + }, + }), + ]), + ).toEqual({ kind: 'none' }); + }); +}); + +describe('legacy migration', () => { + it('creates a fresh active payload at the lifecycle record boundary', () => { + expect( + createMigratedGoalState({ + objective: 'ship it', + goalId: 'new-goal', + recordUuid: 'migration-record', + now: 1000, + }), + ).toEqual({ + v: 2, + cause: 'migrated', + snapshot: { + v: 2, + activity: 'idle', + goal: { + goalId: 'new-goal', + revision: 1, + objective: 'ship it', + status: 'active', + evidenceCursor: { recordId: 'migration-record' }, + turnCount: 0, + activeTimeMs: 0, + createdAt: 1000, + updatedAt: 1000, + }, + }, + }); + }); + + it('rejects an empty migrated objective', () => { + expect(() => + createMigratedGoalState({ + objective: ' ', + goalId: 'new-goal', + recordUuid: 'migration-record', + now: 1000, + }), + ).toThrow('Migrated Goal objective must not be empty'); + }); +}); diff --git a/packages/core/src/goals/goal-persistence.ts b/packages/core/src/goals/goal-persistence.ts new file mode 100644 index 0000000000..6f683341ac --- /dev/null +++ b/packages/core/src/goals/goal-persistence.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + ChatRecord, + SlashCommandRecordPayload, +} from '../services/chatRecordingService.js'; +import { parseGoalStateRecordPayloadV2 } from './goal-reducer.js'; +import { + GOAL_STATE_VERSION, + type GoalStateRecordPayloadV2, +} from './goal-protocol.js'; + +export type GoalRecovery = + | { kind: 'v2'; payload: GoalStateRecordPayloadV2 } + | { kind: 'legacy'; objective: string } + | { kind: 'unsupported'; reason: string } + | { kind: 'none' }; + +export type GoalRecoveryRecord = Pick & { + subtype?: string; + systemPayload?: unknown; +}; + +const LEGACY_ACTIVE_KINDS = new Set(['set', 'checking']); +const LEGACY_STOPPED_KINDS = new Set([ + 'achieved', + 'cleared', + 'failed', + 'aborted', + 'paused', +]); + +export function recoverGoalFromRecords( + records: readonly GoalRecoveryRecord[], +): GoalRecovery { + let unsupported: GoalRecovery | undefined; + for (let index = records.length - 1; index >= 0; index -= 1) { + const record = records[index]; + if (record?.subtype !== 'goal_state') continue; + const payload = + record.type === 'system' + ? parseGoalStateRecordPayloadV2(record.systemPayload) + : undefined; + if (payload) return { kind: 'v2', payload }; + unsupported ??= { + kind: 'unsupported', + reason: `Goal lifecycle record ${record.uuid} is malformed or uses an unsupported version`, + }; + } + + return unsupported ?? recoverLegacyGoal(records); +} + +function recoverLegacyGoal( + records: readonly GoalRecoveryRecord[], +): GoalRecovery { + for ( + let recordIndex = records.length - 1; + recordIndex >= 0; + recordIndex -= 1 + ) { + const record = records[recordIndex]; + if (record?.type !== 'system' || record.subtype !== 'slash_command') { + continue; + } + const payload = record.systemPayload as + | SlashCommandRecordPayload + | undefined; + if ( + payload?.phase !== 'result' || + !Array.isArray(payload.outputHistoryItems) + ) { + continue; + } + for ( + let itemIndex = payload.outputHistoryItems.length - 1; + itemIndex >= 0; + itemIndex -= 1 + ) { + const value: unknown = payload.outputHistoryItems[itemIndex]; + if (!isObjectRecord(value) || value['type'] !== 'goal_status') continue; + const kind = value['kind']; + const condition = value['condition']; + if (typeof kind !== 'string' || typeof condition !== 'string') { + return unsupportedLegacy(record.uuid); + } + if (LEGACY_STOPPED_KINDS.has(kind)) return { kind: 'none' }; + if (!LEGACY_ACTIVE_KINDS.has(kind) || condition.trim().length === 0) { + return unsupportedLegacy(record.uuid); + } + return { kind: 'legacy', objective: condition.trim() }; + } + } + return { kind: 'none' }; +} + +function unsupportedLegacy(recordUuid: string): GoalRecovery { + return { + kind: 'unsupported', + reason: `Legacy Goal record ${recordUuid} cannot be recovered safely`, + }; +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export interface MigratedGoalStateInput { + objective: string; + goalId: string; + recordUuid: string; + now: number; +} + +export function createMigratedGoalState( + input: MigratedGoalStateInput, +): GoalStateRecordPayloadV2 { + const objective = input.objective.trim(); + if (!objective) { + throw new Error('Migrated Goal objective must not be empty'); + } + return { + v: GOAL_STATE_VERSION, + cause: 'migrated', + snapshot: { + v: GOAL_STATE_VERSION, + activity: 'idle', + goal: { + goalId: input.goalId, + revision: 1, + objective, + status: 'active', + evidenceCursor: { recordId: input.recordUuid }, + turnCount: 0, + activeTimeMs: 0, + createdAt: input.now, + updatedAt: input.now, + }, + }, + }; +} diff --git a/packages/core/src/goals/goal-protocol.ts b/packages/core/src/goals/goal-protocol.ts new file mode 100644 index 0000000000..e0cdacbe95 --- /dev/null +++ b/packages/core/src/goals/goal-protocol.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export const GOAL_STATE_VERSION = 2 as const; + +export const PAUSED_GOAL_SYSTEM_REMINDER = + '\nThe Goal is paused. Do not continue its objective unless the user resumes it. Treat this message as ordinary conversation.\n'; + +export type GoalStatus = + | 'active' + | 'paused' + | 'blocked' + | 'usage_limited' + | 'complete'; + +export type GoalActivity = 'idle' | 'running' | 'verifying'; + +export interface TranscriptCursor { + recordId: string | null; +} + +export interface GoalExpectedVersion { + goalId: string; + revision: number; +} + +export interface GoalTurnPermit extends GoalExpectedVersion { + turnId: string; +} + +export interface GoalRecord { + goalId: string; + revision: number; + objective: string; + status: GoalStatus; + evidenceCursor: TranscriptCursor; + turnCount: number; + activeTimeMs: number; + createdAt: number; + updatedAt: number; + lastReason?: string; +} + +export interface GoalSnapshotV2 { + v: typeof GOAL_STATE_VERSION; + goal: GoalRecord | null; + activity: GoalActivity; +} + +/** True while any new model send must carry the runtime's exact turn permit. */ +export function goalRequiresExactPermit(snapshot: GoalSnapshotV2): boolean { + return ( + snapshot.goal !== null && + (snapshot.goal.status === 'active' || snapshot.activity === 'running') + ); +} + +export type GoalControlRequest = + | { action: 'create'; objective: string } + | { + action: 'replace'; + objective: string; + expectedGoalId: string; + expectedRevision: number; + } + | { + action: 'edit'; + objective: string; + expectedGoalId: string; + expectedRevision: number; + } + | { + action: 'pause'; + expectedGoalId: string; + expectedRevision: number; + } + | { + action: 'resume'; + expectedGoalId: string; + expectedRevision: number; + } + | { + action: 'clear'; + expectedGoalId: string; + expectedRevision: number; + }; + +export interface GoalStateResponse { + snapshot: GoalSnapshotV2; +} + +export interface GoalTerminalProposal { + status: 'complete' | 'blocked'; + reason: string; + evidenceRefs: string[]; + blockerKind?: 'authority' | 'external' | 'repeated'; +} + +export type GoalStateCause = + | 'create' + | 'replace' + | 'edit' + | 'pause' + | 'resume' + | 'turn_finished' + | 'verifier_accept' + | 'verifier_reject' + | 'complete' + | 'blocked' + | 'usage_limited' + | 'clear' + | 'migrated'; + +export interface GoalStateRecordPayloadV2 { + v: typeof GOAL_STATE_VERSION; + cause: GoalStateCause; + snapshot: GoalSnapshotV2; + blockedAudit?: { + fingerprint: string; + count: number; + turnIds: string[]; + }; +} diff --git a/packages/core/src/goals/goal-reducer.test.ts b/packages/core/src/goals/goal-reducer.test.ts new file mode 100644 index 0000000000..698af1c1b3 --- /dev/null +++ b/packages/core/src/goals/goal-reducer.test.ts @@ -0,0 +1,505 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + goalRequiresExactPermit, + type GoalControlRequest, + type GoalRecord, + type GoalSnapshotV2, +} from './goal-protocol.js'; +import { + GoalConflictError, + GoalInvalidTransitionError, + elapsedActiveTime, + parseGoalControlRequest, + parseGoalSnapshotV2, + parseGoalStateRecordPayloadV2, + reduceGoalControl, + reduceGoalTurnFinished, +} from './goal-reducer.js'; + +const goalRecord = (overrides: Partial = {}): GoalRecord => ({ + goalId: 'g-1', + revision: 1, + objective: 'ship', + status: 'active', + evidenceCursor: { recordId: 'r-100' }, + turnCount: 0, + activeTimeMs: 0, + createdAt: 100, + updatedAt: 100, + ...overrides, +}); + +const snapshot = (goal: GoalRecord | null): GoalSnapshotV2 => ({ + v: 2, + goal, + activity: 'idle', +}); + +describe('goal reducer', () => { + it('replaces the same objective with a fresh identity and cursor', () => { + const previous = goalRecord({ goalId: 'g-1', objective: 'ship' }); + const next = reduceGoalControl(previous, { + request: { + action: 'replace', + objective: 'ship', + expectedGoalId: 'g-1', + expectedRevision: 1, + }, + now: 200, + nextGoalId: 'g-2', + cursor: { recordId: 'r-200' }, + }); + + expect(next).toMatchObject({ + goalId: 'g-2', + revision: 1, + objective: 'ship', + status: 'active', + evidenceCursor: { recordId: 'r-200' }, + turnCount: 0, + }); + }); + + it('edits in place and rejects evidence from the previous revision', () => { + const previous = goalRecord({ goalId: 'g-1', revision: 4 }); + const next = reduceGoalControl(previous, { + request: { + action: 'edit', + objective: 'new objective', + expectedGoalId: 'g-1', + expectedRevision: 4, + }, + now: 300, + nextGoalId: 'unused', + cursor: { recordId: 'r-300' }, + }); + + expect(next).toMatchObject({ + goalId: 'g-1', + revision: 5, + objective: 'new objective', + evidenceCursor: { recordId: 'r-300' }, + }); + }); + + it('creates a trimmed active goal only when no goal exists', () => { + const next = reduceGoalControl(null, { + request: { action: 'create', objective: ' ship ' }, + now: 100, + nextGoalId: 'g-1', + cursor: { recordId: 'r-100' }, + }); + + expect(next).toEqual(goalRecord()); + expect(() => + reduceGoalControl(next, { + request: { action: 'create', objective: 'another' }, + now: 200, + nextGoalId: 'g-2', + cursor: { recordId: 'r-200' }, + }), + ).toThrow(GoalConflictError); + }); + + it('rejects empty objectives', () => { + expect(() => + reduceGoalControl(null, { + request: { action: 'create', objective: ' \n ' }, + now: 100, + nextGoalId: 'g-1', + cursor: { recordId: 'r-100' }, + }), + ).toThrow(GoalInvalidTransitionError); + }); + + it('returns the current snapshot for stale identity and revision', () => { + const previous = goalRecord({ revision: 4 }); + + for (const request of [ + { + action: 'pause' as const, + expectedGoalId: 'g-other', + expectedRevision: 4, + }, + { + action: 'pause' as const, + expectedGoalId: 'g-1', + expectedRevision: 3, + }, + ]) { + try { + reduceGoalControl(previous, { + request, + now: 200, + nextGoalId: 'unused', + cursor: { recordId: 'r-200' }, + }); + throw new Error('expected conflict'); + } catch (error) { + expect(error).toBeInstanceOf(GoalConflictError); + expect((error as GoalConflictError).current).toEqual( + snapshot(previous), + ); + } + } + }); + + it('pauses and resumes without changing revision or evidence cursor', () => { + const paused = reduceGoalControl(goalRecord(), { + request: { + action: 'pause', + expectedGoalId: 'g-1', + expectedRevision: 1, + }, + now: 150, + nextGoalId: 'unused', + cursor: { recordId: 'r-150' }, + }); + const resumed = reduceGoalControl(paused, { + request: { + action: 'resume', + expectedGoalId: 'g-1', + expectedRevision: 1, + }, + now: 200, + nextGoalId: 'unused', + cursor: { recordId: 'r-200' }, + }); + + expect(paused).toMatchObject({ + status: 'paused', + revision: 1, + evidenceCursor: { recordId: 'r-100' }, + }); + expect(resumed).toMatchObject({ + status: 'active', + revision: 1, + evidenceCursor: { recordId: 'r-100' }, + }); + }); + + it('rejects resuming an already-active goal', () => { + expect(() => + reduceGoalControl(goalRecord(), { + request: { + action: 'resume', + expectedGoalId: 'g-1', + expectedRevision: 1, + }, + now: 200, + nextGoalId: 'unused', + cursor: { recordId: 'r-200' }, + }), + ).toThrow(GoalInvalidTransitionError); + }); + + it.each(['blocked', 'usage_limited'] as const)( + 'resumes a %s goal without changing revision or evidence cursor', + (status) => { + const resumed = reduceGoalControl(goalRecord({ status, revision: 4 }), { + request: { + action: 'resume', + expectedGoalId: 'g-1', + expectedRevision: 4, + }, + now: 200, + nextGoalId: 'unused', + cursor: { recordId: 'r-200' }, + }); + + expect(resumed).toMatchObject({ + status: 'active', + revision: 4, + evidenceCursor: { recordId: 'r-100' }, + }); + }, + ); + + it('rejects an unsupported control action instead of resuming', () => { + expect(() => + reduceGoalControl(goalRecord({ status: 'paused' }), { + request: { + action: 'archive', + expectedGoalId: 'g-1', + expectedRevision: 1, + } as unknown as GoalControlRequest, + now: 200, + nextGoalId: 'unused', + cursor: { recordId: 'r-200' }, + }), + ).toThrow(GoalInvalidTransitionError); + }); + + it.each(['paused', 'blocked', 'usage_limited'] as const)( + 'edits a %s goal without changing its status', + (status) => { + const next = reduceGoalControl(goalRecord({ status, revision: 4 }), { + request: { + action: 'edit', + objective: 'new objective', + expectedGoalId: 'g-1', + expectedRevision: 4, + }, + now: 300, + nextGoalId: 'unused', + cursor: { recordId: 'r-300' }, + }); + + expect(next).toMatchObject({ status, revision: 5 }); + }, + ); + + it('rejects editing or resuming a completed goal', () => { + const complete = goalRecord({ status: 'complete' }); + + for (const request of [ + { + action: 'edit' as const, + objective: 'new objective', + expectedGoalId: 'g-1', + expectedRevision: 1, + }, + { + action: 'resume' as const, + expectedGoalId: 'g-1', + expectedRevision: 1, + }, + ]) { + expect(() => + reduceGoalControl(complete, { + request, + now: 200, + nextGoalId: 'unused', + cursor: { recordId: 'r-200' }, + }), + ).toThrow(GoalInvalidTransitionError); + } + }); + + it('clears a matching goal', () => { + expect( + reduceGoalControl(goalRecord(), { + request: { + action: 'clear', + expectedGoalId: 'g-1', + expectedRevision: 1, + }, + now: 200, + nextGoalId: 'unused', + cursor: { recordId: 'r-200' }, + }), + ).toBeNull(); + }); + + it('folds active elapsed time before each persisted transition', () => { + const paused = reduceGoalControl(goalRecord(), { + request: { + action: 'pause', + expectedGoalId: 'g-1', + expectedRevision: 1, + }, + now: 160, + nextGoalId: 'unused', + cursor: { recordId: 'r-160' }, + }); + const resumed = reduceGoalControl(paused, { + request: { + action: 'resume', + expectedGoalId: 'g-1', + expectedRevision: 1, + }, + now: 250, + nextGoalId: 'unused', + cursor: { recordId: 'r-250' }, + }); + const pausedAgain = reduceGoalControl(resumed, { + request: { + action: 'pause', + expectedGoalId: 'g-1', + expectedRevision: 1, + }, + now: 275, + nextGoalId: 'unused', + cursor: { recordId: 'r-275' }, + }); + + expect(paused?.activeTimeMs).toBe(60); + expect(resumed?.activeTimeMs).toBe(60); + expect(pausedAgain?.activeTimeMs).toBe(85); + expect(elapsedActiveTime(resumed!, 275)).toBe(85); + }); + + it('never derives a terminal status from turn count or elapsed time', () => { + let goal = goalRecord(); + for (let turn = 1; turn <= 150; turn += 1) { + goal = reduceGoalTurnFinished(goal, { + now: 100 + turn, + }); + } + + expect(goal).toMatchObject({ + status: 'active', + revision: 1, + turnCount: 150, + activeTimeMs: 150, + evidenceCursor: { recordId: 'r-100' }, + }); + }); + + it('finishes an in-flight turn after pause without resuming active time', () => { + const paused = goalRecord({ + revision: 4, + status: 'paused', + turnCount: 2, + activeTimeMs: 60, + updatedAt: 160, + }); + + const finished = reduceGoalTurnFinished(paused, { now: 225 }); + + expect(finished).toMatchObject({ + status: 'paused', + revision: 4, + evidenceCursor: { recordId: 'r-100' }, + turnCount: 3, + activeTimeMs: 60, + updatedAt: 225, + }); + }); + + it.each(['blocked', 'usage_limited', 'complete'] as const)( + 'rejects finishing a turn for a %s goal', + (status) => { + expect(() => + reduceGoalTurnFinished(goalRecord({ status }), { now: 200 }), + ).toThrow(GoalInvalidTransitionError); + }, + ); + + it.each([ + [null, 'idle', false], + [goalRecord(), 'idle', true], + [goalRecord({ status: 'paused' }), 'idle', false], + [goalRecord({ status: 'paused' }), 'running', true], + ] as const)( + 'requires an exact permit for the matching goal and activity state', + (goal, activity, expected) => { + expect(goalRequiresExactPermit({ ...snapshot(goal), activity })).toBe( + expected, + ); + }, + ); + + it('strictly parses persisted idle goal snapshots and control requests', () => { + const record = goalRecord(); + expect( + parseGoalStateRecordPayloadV2({ + v: 2, + cause: 'create', + snapshot: snapshot(record), + }), + ).toEqual({ v: 2, cause: 'create', snapshot: snapshot(record) }); + expect( + parseGoalStateRecordPayloadV2({ + v: 2, + cause: 'create', + snapshot: { ...snapshot(record), activity: 'running' }, + }), + ).toBeUndefined(); + expect( + parseGoalControlRequest({ action: 'create', objective: 'ship' }), + ).toEqual({ + action: 'create', + objective: 'ship', + }); + expect( + parseGoalControlRequest({ + action: 'edit', + objective: ' ', + expectedGoalId: 'g-1', + expectedRevision: 1, + }), + ).toBeUndefined(); + expect( + parseGoalControlRequest({ + action: 'pause', + expectedGoalId: 'g-1', + }), + ).toBeUndefined(); + expect( + parseGoalControlRequest({ + action: 'pause', + expectedGoalId: 'g-1', + expectedRevision: 0, + }), + ).toBeUndefined(); + }); + + it.each(['idle', 'running', 'verifying'] as const)( + 'parses %s activity in public wire snapshots', + (activity) => { + const value = { ...snapshot(goalRecord()), activity }; + + expect(parseGoalSnapshotV2(value)).toEqual(value); + }, + ); + + it.each([ + ['zero count', { fingerprint: 'same', count: 0, turnIds: [] }], + [ + 'count above the blocker threshold', + { + fingerprint: 'same', + count: 4, + turnIds: ['turn-1', 'turn-2', 'turn-3', 'turn-4'], + }, + ], + [ + 'count and turn ID mismatch', + { fingerprint: 'same', count: 2, turnIds: ['turn-1'] }, + ], + ['empty fingerprint', { fingerprint: '', count: 1, turnIds: ['turn-1'] }], + ['empty turn ID', { fingerprint: 'same', count: 1, turnIds: [''] }], + [ + 'extra key', + { + fingerprint: 'same', + count: 1, + turnIds: ['turn-1'], + unexpected: true, + }, + ], + ])('rejects a blocked audit with %s', (_label, blockedAudit) => { + expect( + parseGoalStateRecordPayloadV2({ + v: 2, + cause: 'turn_finished', + snapshot: snapshot(goalRecord()), + blockedAudit, + }), + ).toBeUndefined(); + }); + + it('parses and clones a valid blocked audit', () => { + const blockedAudit = { + fingerprint: 'same', + count: 2, + turnIds: ['turn-1', 'turn-2'], + }; + const parsed = parseGoalStateRecordPayloadV2({ + v: 2, + cause: 'turn_finished', + snapshot: snapshot(goalRecord()), + blockedAudit, + }); + + expect(parsed?.blockedAudit).toEqual(blockedAudit); + expect(parsed?.blockedAudit).not.toBe(blockedAudit); + }); +}); diff --git a/packages/core/src/goals/goal-reducer.ts b/packages/core/src/goals/goal-reducer.ts new file mode 100644 index 0000000000..ae2bf87b06 --- /dev/null +++ b/packages/core/src/goals/goal-reducer.ts @@ -0,0 +1,485 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + GOAL_STATE_VERSION, + type GoalControlRequest, + type GoalRecord, + type GoalSnapshotV2, + type GoalStateCause, + type GoalStateRecordPayloadV2, + type GoalStatus, + type TranscriptCursor, +} from './goal-protocol.js'; + +const MAX_BLOCKED_AUDIT_COUNT = 3; + +export interface GoalControlTransition { + request: GoalControlRequest; + now: number; + nextGoalId: string; + cursor: TranscriptCursor; +} + +export interface GoalTurnFinishedTransition { + now: number; + lastReason?: string; +} + +export class GoalConflictError extends Error { + constructor(readonly current: GoalSnapshotV2) { + super('Goal version does not match the current session Goal'); + this.name = 'GoalConflictError'; + } +} + +export class GoalInvalidTransitionError extends Error { + constructor( + message: string, + readonly current: GoalSnapshotV2, + ) { + super(message); + this.name = 'GoalInvalidTransitionError'; + } +} + +export function elapsedActiveTime(goal: GoalRecord, now: number): number { + return ( + goal.activeTimeMs + + (goal.status === 'active' ? Math.max(0, now - goal.updatedAt) : 0) + ); +} + +export function reduceGoalControl( + current: GoalRecord | null, + transition: GoalControlTransition, +): GoalRecord | null { + const { request } = transition; + if (request.action === 'create') { + if (current) throw new GoalConflictError(snapshotOf(current)); + return createGoal( + transition.nextGoalId, + normalizeObjective(request.objective, snapshotOf(null)), + transition.now, + transition.cursor, + ); + } + + assertExpectedVersion( + current, + request.expectedGoalId, + request.expectedRevision, + ); + + if (request.action === 'clear') return null; + + if (request.action === 'replace') { + return createGoal( + transition.nextGoalId, + normalizeObjective(request.objective, snapshotOf(current)), + transition.now, + transition.cursor, + ); + } + + if (request.action === 'edit') { + if (current.status === 'complete') { + throw new GoalInvalidTransitionError( + 'A completed Goal cannot be edited', + snapshotOf(current), + ); + } + return transitionGoal(current, transition.now, { + revision: current.revision + 1, + objective: normalizeObjective(request.objective, snapshotOf(current)), + evidenceCursor: copyCursor(transition.cursor), + }); + } + + if (request.action === 'pause') { + if (current.status !== 'active') { + throw new GoalInvalidTransitionError( + 'Only an active Goal can be paused', + snapshotOf(current), + ); + } + return transitionGoal(current, transition.now, { status: 'paused' }); + } + + if (current.status === 'complete') { + throw new GoalInvalidTransitionError( + 'A completed Goal cannot be resumed', + snapshotOf(current), + ); + } + if (current.status === 'active') { + throw new GoalInvalidTransitionError( + 'An active Goal cannot be resumed', + snapshotOf(current), + ); + } + if (request.action !== 'resume') { + return assertNever(request, snapshotOf(current)); + } + return transitionGoal(current, transition.now, { status: 'active' }); +} + +export function reduceGoalTurnFinished( + current: GoalRecord, + transition: GoalTurnFinishedTransition, +): GoalRecord { + if (current.status !== 'active' && current.status !== 'paused') { + throw new GoalInvalidTransitionError( + 'Only an active or paused Goal can finish a turn', + snapshotOf(current), + ); + } + return transitionGoal(current, transition.now, { + turnCount: current.turnCount + 1, + ...(transition.lastReason === undefined + ? {} + : { lastReason: transition.lastReason }), + }); +} + +export function parseGoalControlRequest( + value: unknown, +): GoalControlRequest | undefined { + if (!isRecord(value) || typeof value['action'] !== 'string') { + return undefined; + } + + switch (value['action']) { + case 'create': + if (!hasOnlyKeys(value, ['action', 'objective'])) return undefined; + return typeof value['objective'] === 'string' + ? parseObjectiveRequest(value['action'], value['objective']) + : undefined; + case 'replace': + case 'edit': + if ( + !hasOnlyKeys(value, [ + 'action', + 'objective', + 'expectedGoalId', + 'expectedRevision', + ]) || + typeof value['objective'] !== 'string' || + !isExpectedVersion(value) + ) { + return undefined; + } + return parseObjectiveVersionedRequest( + value['action'], + value['objective'], + value['expectedGoalId'], + value['expectedRevision'], + ); + case 'pause': + case 'resume': + case 'clear': + if ( + !hasOnlyKeys(value, ['action', 'expectedGoalId', 'expectedRevision']) || + !isExpectedVersion(value) + ) { + return undefined; + } + return { + action: value['action'], + expectedGoalId: value['expectedGoalId'], + expectedRevision: value['expectedRevision'], + }; + default: + return undefined; + } +} + +export function parseGoalStateRecordPayloadV2( + value: unknown, +): GoalStateRecordPayloadV2 | undefined { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ['v', 'cause', 'snapshot', 'blockedAudit']) || + value['v'] !== GOAL_STATE_VERSION || + !isGoalStateCause(value['cause']) || + !isBlockedAudit(value['blockedAudit']) + ) { + return undefined; + } + const parsedSnapshot = parseGoalSnapshotV2(value['snapshot']); + return parsedSnapshot?.activity === 'idle' + ? { + v: GOAL_STATE_VERSION, + cause: value['cause'], + snapshot: parsedSnapshot, + ...(value['blockedAudit'] + ? { blockedAudit: structuredClone(value['blockedAudit']) } + : {}), + } + : undefined; +} + +export function parseGoalSnapshotV2( + value: unknown, +): GoalSnapshotV2 | undefined { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ['v', 'goal', 'activity']) || + value['v'] !== GOAL_STATE_VERSION || + !isGoalActivity(value['activity']) + ) { + return undefined; + } + if (value['goal'] === null) { + return { + v: GOAL_STATE_VERSION, + goal: null, + activity: value['activity'], + }; + } + const goal = parseGoalRecord(value['goal']); + return goal + ? { v: GOAL_STATE_VERSION, goal, activity: value['activity'] } + : undefined; +} + +function createGoal( + goalId: string, + objective: string, + now: number, + cursor: TranscriptCursor, +): GoalRecord { + return { + goalId, + revision: 1, + objective, + status: 'active', + evidenceCursor: copyCursor(cursor), + turnCount: 0, + activeTimeMs: 0, + createdAt: now, + updatedAt: now, + }; +} + +function assertExpectedVersion( + current: GoalRecord | null, + expectedGoalId: string, + expectedRevision: number, +): asserts current is GoalRecord { + if ( + !current || + current.goalId !== expectedGoalId || + current.revision !== expectedRevision + ) { + throw new GoalConflictError(snapshotOf(current)); + } +} + +function normalizeObjective( + objective: string, + current: GoalSnapshotV2, +): string { + const normalized = objective.trim(); + if (!normalized) { + throw new GoalInvalidTransitionError( + 'Goal objective must not be empty', + current, + ); + } + return normalized; +} + +function transitionGoal( + goal: GoalRecord, + now: number, + changes: Partial, +): GoalRecord { + return { + ...goal, + ...changes, + activeTimeMs: elapsedActiveTime(goal, now), + updatedAt: now, + }; +} + +function snapshotOf(goal: GoalRecord | null): GoalSnapshotV2 { + return { v: GOAL_STATE_VERSION, goal, activity: 'idle' }; +} + +function copyCursor(cursor: TranscriptCursor): TranscriptCursor { + return { recordId: cursor.recordId }; +} + +function parseObjectiveRequest( + action: 'create', + objective: string, +): GoalControlRequest | undefined { + const normalized = objective.trim(); + return normalized ? { action, objective: normalized } : undefined; +} + +function parseObjectiveVersionedRequest( + action: 'replace' | 'edit', + objective: string, + expectedGoalId: string, + expectedRevision: number, +): GoalControlRequest | undefined { + const normalized = objective.trim(); + return normalized + ? { action, objective: normalized, expectedGoalId, expectedRevision } + : undefined; +} + +function parseGoalRecord(value: unknown): GoalRecord | undefined { + if ( + !isRecord(value) || + !hasOnlyKeys(value, [ + 'goalId', + 'revision', + 'objective', + 'status', + 'evidenceCursor', + 'turnCount', + 'activeTimeMs', + 'createdAt', + 'updatedAt', + 'lastReason', + ]) || + typeof value['goalId'] !== 'string' || + !value['goalId'] || + !isNonNegativeInteger(value['revision']) || + value['revision'] === 0 || + typeof value['objective'] !== 'string' || + !value['objective'].trim() || + !isGoalStatus(value['status']) || + !isTranscriptCursor(value['evidenceCursor']) || + !isNonNegativeInteger(value['turnCount']) || + !isNonNegativeNumber(value['activeTimeMs']) || + !isFiniteNumber(value['createdAt']) || + !isFiniteNumber(value['updatedAt']) || + (value['lastReason'] !== undefined && + typeof value['lastReason'] !== 'string') + ) { + return undefined; + } + return { + goalId: value['goalId'], + revision: value['revision'], + objective: value['objective'], + status: value['status'], + evidenceCursor: copyCursor(value['evidenceCursor']), + turnCount: value['turnCount'], + activeTimeMs: value['activeTimeMs'], + createdAt: value['createdAt'], + updatedAt: value['updatedAt'], + ...(value['lastReason'] === undefined + ? {} + : { lastReason: value['lastReason'] }), + }; +} + +function isExpectedVersion(value: Record): value is Record< + string, + unknown +> & { + expectedGoalId: string; + expectedRevision: number; +} { + return ( + typeof value['expectedGoalId'] === 'string' && + value['expectedGoalId'].length > 0 && + isNonNegativeInteger(value['expectedRevision']) && + value['expectedRevision'] > 0 + ); +} + +function isTranscriptCursor(value: unknown): value is TranscriptCursor { + return ( + isRecord(value) && + hasOnlyKeys(value, ['recordId']) && + (typeof value['recordId'] === 'string' || value['recordId'] === null) + ); +} + +function isGoalStatus(value: unknown): value is GoalStatus { + return ( + value === 'active' || + value === 'paused' || + value === 'blocked' || + value === 'usage_limited' || + value === 'complete' + ); +} + +function isGoalActivity(value: unknown): value is GoalSnapshotV2['activity'] { + return value === 'idle' || value === 'running' || value === 'verifying'; +} + +function isGoalStateCause(value: unknown): value is GoalStateCause { + return ( + value === 'create' || + value === 'replace' || + value === 'edit' || + value === 'pause' || + value === 'resume' || + value === 'turn_finished' || + value === 'verifier_accept' || + value === 'verifier_reject' || + value === 'complete' || + value === 'blocked' || + value === 'usage_limited' || + value === 'clear' || + value === 'migrated' + ); +} + +function isBlockedAudit( + value: unknown, +): value is GoalStateRecordPayloadV2['blockedAudit'] { + return ( + value === undefined || + (isRecord(value) && + hasOnlyKeys(value, ['fingerprint', 'count', 'turnIds']) && + typeof value['fingerprint'] === 'string' && + value['fingerprint'].length > 0 && + isNonNegativeInteger(value['count']) && + value['count'] > 0 && + value['count'] <= MAX_BLOCKED_AUDIT_COUNT && + Array.isArray(value['turnIds']) && + value['turnIds'].length === value['count'] && + value['turnIds'].every( + (turnId) => typeof turnId === 'string' && turnId.length > 0, + )) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasOnlyKeys(value: Record, keys: string[]): boolean { + return Object.keys(value).every((key) => keys.includes(key)); +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0; +} + +function isNonNegativeNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function assertNever(value: never, snapshot: GoalSnapshotV2): never { + throw new GoalInvalidTransitionError( + `Unsupported Goal control action: ${String(value)}`, + snapshot, + ); +} diff --git a/packages/core/src/goals/goal-wire.ts b/packages/core/src/goals/goal-wire.ts new file mode 100644 index 0000000000..4789ffff83 --- /dev/null +++ b/packages/core/src/goals/goal-wire.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export { + GOAL_STATE_VERSION, + goalRequiresExactPermit, + type GoalActivity, + type GoalControlRequest, + type GoalExpectedVersion, + type GoalRecord, + type GoalSnapshotV2, + type GoalStateCause, + type GoalStateRecordPayloadV2, + type GoalStateResponse, + type GoalStatus, + type GoalTerminalProposal, + type GoalTurnPermit, + type TranscriptCursor, +} from './goal-protocol.js'; +export { + parseGoalSnapshotV2, + parseGoalStateRecordPayloadV2, +} from './goal-reducer.js'; +export { + projectGoalStateToLegacy, + type LegacyActiveGoal, + type LegacyGoalProjection, + type LegacyGoalStatus, + type LegacyGoalStatusKind, + type LegacyGoalTerminal, +} from './goal-legacy-projection.js'; diff --git a/packages/core/src/goals/index.ts b/packages/core/src/goals/index.ts index 3aa34bc598..616707d306 100644 --- a/packages/core/src/goals/index.ts +++ b/packages/core/src/goals/index.ts @@ -35,3 +35,27 @@ export { } from './goalHook.js'; export { judgeGoal } from './goalJudge.js'; export type { GoalJudgeOutcome, JudgeResult } from './goalJudge.js'; +export * from './goal-protocol.js'; +export { + GoalConflictError, + GoalInvalidTransitionError, + elapsedActiveTime, + parseGoalControlRequest, + parseGoalSnapshotV2, + parseGoalStateRecordPayloadV2, + reduceGoalControl, + reduceGoalTurnFinished, +} from './goal-reducer.js'; +export type { + GoalControlTransition, + GoalTurnFinishedTransition, +} from './goal-reducer.js'; +export * from './goal-persistence.js'; +export { projectGoalStateToLegacy } from './goal-legacy-projection.js'; +export type { + LegacyActiveGoal, + LegacyGoalProjection, + LegacyGoalStatus, + LegacyGoalStatusKind, + LegacyGoalTerminal, +} from './goal-legacy-projection.js';