diff --git a/apps/server/src/decision/Errors.ts b/apps/server/src/decision/Errors.ts new file mode 100644 index 000000000..8da0262d5 --- /dev/null +++ b/apps/server/src/decision/Errors.ts @@ -0,0 +1,16 @@ +import { Schema } from "effect"; + +export class DecisionWorkspaceError extends Schema.TaggedErrorClass()( + "DecisionWorkspaceError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Decision workspace failed in ${this.operation}: ${this.detail}`; + } +} + +export type DecisionWorkspaceServiceError = DecisionWorkspaceError; diff --git a/apps/server/src/decision/Services/DecisionConsultationService.ts b/apps/server/src/decision/Services/DecisionConsultationService.ts new file mode 100644 index 000000000..0e7ec10c7 --- /dev/null +++ b/apps/server/src/decision/Services/DecisionConsultationService.ts @@ -0,0 +1,30 @@ +import type { + DecisionCase, + DecisionConsultation, + DecisionConsultationQuestion, + DecisionContextPack, +} from "@okcode/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; +import type { DecisionPolicyDefinition } from "./DecisionPolicy.ts"; +import type { DecisionWorkspaceServiceError } from "../Errors.ts"; + +export interface DecisionConsultationServiceShape { + readonly request: (input: { + readonly decisionCase: DecisionCase; + readonly target: "operator" | "orchestrator"; + readonly reason: string; + readonly questions: ReadonlyArray; + readonly contextPack: DecisionContextPack; + readonly policy: DecisionPolicyDefinition; + }) => Effect.Effect; + readonly respond: (input: { + readonly consultationId: string; + readonly resolution: string; + }) => Effect.Effect; +} + +export class DecisionConsultationService extends ServiceMap.Service< + DecisionConsultationService, + DecisionConsultationServiceShape +>()("okcode/decision/Services/DecisionConsultationService") {} diff --git a/apps/server/src/decision/Services/DecisionContextPackBuilder.ts b/apps/server/src/decision/Services/DecisionContextPackBuilder.ts new file mode 100644 index 000000000..49da0708d --- /dev/null +++ b/apps/server/src/decision/Services/DecisionContextPackBuilder.ts @@ -0,0 +1,32 @@ +import type { + DecisionCase, + DecisionContextPack, + DecisionPrincipleResult, + DecisionRecommendation, +} from "@okcode/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; +import type { DecisionPolicyDefinition } from "./DecisionPolicy.ts"; +import type { DecisionWorkspaceServiceError } from "../Errors.ts"; + +export interface DecisionContextPackBuilderShape { + readonly listAutoCases: (input: { + readonly cwd: string; + }) => Effect.Effect, DecisionWorkspaceServiceError>; + readonly buildCaseArtifacts: (input: { + readonly decisionCase: DecisionCase; + readonly policy: DecisionPolicyDefinition; + }) => Effect.Effect< + { + readonly contextPack: DecisionContextPack; + readonly principles: ReadonlyArray; + readonly recommendations: ReadonlyArray; + }, + DecisionWorkspaceServiceError + >; +} + +export class DecisionContextPackBuilder extends ServiceMap.Service< + DecisionContextPackBuilder, + DecisionContextPackBuilderShape +>()("okcode/decision/Services/DecisionContextPackBuilder") {} diff --git a/apps/server/src/decision/Services/DecisionPolicy.ts b/apps/server/src/decision/Services/DecisionPolicy.ts new file mode 100644 index 000000000..307ab5df1 --- /dev/null +++ b/apps/server/src/decision/Services/DecisionPolicy.ts @@ -0,0 +1,33 @@ +import type { DecisionConflictKind } from "@okcode/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; +import type { DecisionWorkspaceServiceError } from "../Errors.ts"; + +export interface DecisionPolicyDefinition { + readonly version: string; + readonly principles: ReadonlyArray<{ + readonly id: string; + readonly label: string; + readonly blocking: boolean; + }>; + readonly executionThresholds: { + readonly autoExecuteScore: number; + readonly minimumContextCompleteness: number; + readonly minimumPolicyAlignment: number; + }; + readonly requiredContextByKind: Readonly>>; + readonly consultationDefaults: { + readonly orchestratorMinScore: number; + readonly operatorMaxScore: number; + }; +} + +export interface DecisionPolicyShape { + readonly getPolicy: (input: { + readonly cwd: string; + }) => Effect.Effect; +} + +export class DecisionPolicy extends ServiceMap.Service()( + "okcode/decision/Services/DecisionPolicy", +) {} diff --git a/apps/server/src/decision/Services/DecisionProjection.ts b/apps/server/src/decision/Services/DecisionProjection.ts new file mode 100644 index 000000000..8d87c3394 --- /dev/null +++ b/apps/server/src/decision/Services/DecisionProjection.ts @@ -0,0 +1,56 @@ +import type { + DecisionCase, + DecisionConfidenceAnalysis, + DecisionConsultation, + DecisionConsultationQuestion, + DecisionConsultationStatus, + DecisionConsultationTarget, +} from "@okcode/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; +import type { DecisionWorkspaceServiceError } from "../Errors.ts"; + +export interface DecisionProjectionShape { + readonly upsertCase: (input: DecisionCase) => Effect.Effect; + readonly getCase: (input: { + readonly caseId: string; + }) => Effect.Effect; + readonly listCasesByCwd: (input: { + readonly cwd: string; + }) => Effect.Effect, DecisionWorkspaceServiceError>; + readonly upsertConsultation: (input: { + readonly consultation: DecisionConsultation; + readonly questions: ReadonlyArray; + }) => Effect.Effect; + readonly getConsultation: (input: { + readonly consultationId: string; + }) => Effect.Effect< + { consultation: DecisionConsultation; questions: ReadonlyArray } | null, + DecisionWorkspaceServiceError + >; + readonly listConsultationsByCaseId: (input: { + readonly caseId: string; + }) => Effect.Effect, DecisionWorkspaceServiceError>; + readonly appendScoreSnapshot: (input: { + readonly caseId: string; + readonly analysis: DecisionConfidenceAnalysis; + }) => Effect.Effect; + readonly listScoreSnapshots: (input: { + readonly caseId: string; + }) => Effect.Effect, DecisionWorkspaceServiceError>; + readonly createConsultation: (input: { + readonly caseId: string; + readonly target: DecisionConsultationTarget; + readonly status: DecisionConsultationStatus; + readonly reason: string; + readonly questions: ReadonlyArray; + readonly linkedThreadId: string | null; + readonly responseSummary?: string | null; + readonly resolvedAt?: string | null; + }) => Effect.Effect; +} + +export class DecisionProjection extends ServiceMap.Service< + DecisionProjection, + DecisionProjectionShape +>()("okcode/decision/Services/DecisionProjection") {} diff --git a/apps/server/src/decision/Services/DecisionWorkspace.ts b/apps/server/src/decision/Services/DecisionWorkspace.ts new file mode 100644 index 000000000..7e2804f74 --- /dev/null +++ b/apps/server/src/decision/Services/DecisionWorkspace.ts @@ -0,0 +1,39 @@ +import type { + DecisionCaseSummary, + DecisionExecuteRecommendationInput, + DecisionExecutionResult, + DecisionGetWorkspaceInput, + DecisionListCasesInput, + DecisionRequestConsultationInput, + DecisionRespondConsultationInput, + DecisionWorkspace as DecisionWorkspaceResult, +} from "@okcode/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; +import type { DecisionWorkspaceServiceError } from "../Errors.ts"; + +export interface DecisionWorkspaceShape { + readonly listCases: ( + input: DecisionListCasesInput, + ) => Effect.Effect, DecisionWorkspaceServiceError>; + readonly getWorkspace: ( + input: DecisionGetWorkspaceInput, + ) => Effect.Effect; + readonly reanalyze: ( + input: DecisionGetWorkspaceInput, + ) => Effect.Effect; + readonly requestConsultation: ( + input: DecisionRequestConsultationInput, + ) => Effect.Effect; + readonly respondConsultation: ( + input: DecisionRespondConsultationInput, + ) => Effect.Effect; + readonly executeRecommendation: ( + input: DecisionExecuteRecommendationInput, + ) => Effect.Effect; +} + +export class DecisionWorkspace extends ServiceMap.Service< + DecisionWorkspace, + DecisionWorkspaceShape +>()("okcode/decision/Services/DecisionWorkspace") {} diff --git a/apps/server/src/persistence/Layers/DecisionCases.ts b/apps/server/src/persistence/Layers/DecisionCases.ts new file mode 100644 index 000000000..185a54822 --- /dev/null +++ b/apps/server/src/persistence/Layers/DecisionCases.ts @@ -0,0 +1,134 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Option, Schema } from "effect"; +import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; +import { + DecisionCaseRepository, + DecisionCaseRow, + GetDecisionCaseInput, + ListDecisionCasesInput, + type DecisionCaseRepositoryShape, +} from "../Services/DecisionCases.ts"; + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeDecisionCaseRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertRow = SqlSchema.void({ + Request: DecisionCaseRow, + execute: (row) => + sql` + INSERT INTO decision_cases ( + case_id, project_id, cwd, source_kind, source_id, title, conflict_kind, + linked_thread_id, created_at, updated_at + ) VALUES ( + ${row.caseId}, ${row.projectId}, ${row.cwd}, ${row.sourceKind}, ${row.sourceId}, ${row.title}, + ${row.conflictKind}, ${row.linkedThreadId}, ${row.createdAt}, ${row.updatedAt} + ) + ON CONFLICT (case_id) + DO UPDATE SET + project_id = excluded.project_id, + cwd = excluded.cwd, + source_kind = excluded.source_kind, + source_id = excluded.source_id, + title = excluded.title, + conflict_kind = excluded.conflict_kind, + linked_thread_id = excluded.linked_thread_id, + updated_at = excluded.updated_at + `, + }); + + const getRow = SqlSchema.findOneOption({ + Request: GetDecisionCaseInput, + Result: DecisionCaseRow, + execute: ({ caseId }) => + sql` + SELECT + case_id AS "caseId", + project_id AS "projectId", + cwd, + source_kind AS "sourceKind", + source_id AS "sourceId", + title, + conflict_kind AS "conflictKind", + linked_thread_id AS "linkedThreadId", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM decision_cases + WHERE case_id = ${caseId} + `, + }); + + const listRows = SqlSchema.findAll({ + Request: ListDecisionCasesInput, + Result: DecisionCaseRow, + execute: ({ cwd }) => + sql` + SELECT + case_id AS "caseId", + project_id AS "projectId", + cwd, + source_kind AS "sourceKind", + source_id AS "sourceId", + title, + conflict_kind AS "conflictKind", + linked_thread_id AS "linkedThreadId", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM decision_cases + WHERE cwd = ${cwd} + ORDER BY updated_at DESC + `, + }); + + const upsert: DecisionCaseRepositoryShape["upsert"] = (row) => + upsertRow(row).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "DecisionCaseRepository.upsert:query", + "DecisionCaseRepository.upsert:encodeRequest", + ), + ), + ); + + const getById: DecisionCaseRepositoryShape["getById"] = (input) => + getRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "DecisionCaseRepository.getById:query", + "DecisionCaseRepository.getById:decodeRow", + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + Effect.succeed(Option.some(row as Schema.Schema.Type)), + }), + ), + ); + + const listByCwd: DecisionCaseRepositoryShape["listByCwd"] = (input) => + listRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "DecisionCaseRepository.listByCwd:query", + "DecisionCaseRepository.listByCwd:decodeRows", + ), + ), + Effect.map((rows) => rows as ReadonlyArray>), + ); + + return { upsert, getById, listByCwd } satisfies DecisionCaseRepositoryShape; +}); + +export const DecisionCaseRepositoryLive = Layer.effect( + DecisionCaseRepository, + makeDecisionCaseRepository, +); diff --git a/apps/server/src/persistence/Layers/DecisionConsultations.ts b/apps/server/src/persistence/Layers/DecisionConsultations.ts new file mode 100644 index 000000000..e446d9449 --- /dev/null +++ b/apps/server/src/persistence/Layers/DecisionConsultations.ts @@ -0,0 +1,140 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Option, Schema } from "effect"; +import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; +import { + DecisionConsultationRepository, + DecisionConsultationRow, + GetDecisionConsultationInput, + ListDecisionConsultationsInput, + type DecisionConsultationRepositoryShape, +} from "../Services/DecisionConsultations.ts"; + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeDecisionConsultationRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertRow = SqlSchema.void({ + Request: DecisionConsultationRow, + execute: (row) => + sql` + INSERT INTO decision_consultations ( + consultation_id, case_id, target, status, reason, questions_json, response_summary, + linked_thread_id, created_at, updated_at, resolved_at + ) VALUES ( + ${row.consultationId}, ${row.caseId}, ${row.target}, ${row.status}, ${row.reason}, + ${row.questionsJson}, ${row.responseSummary}, ${row.linkedThreadId}, ${row.createdAt}, + ${row.updatedAt}, ${row.resolvedAt} + ) + ON CONFLICT (consultation_id) + DO UPDATE SET + case_id = excluded.case_id, + target = excluded.target, + status = excluded.status, + reason = excluded.reason, + questions_json = excluded.questions_json, + response_summary = excluded.response_summary, + linked_thread_id = excluded.linked_thread_id, + updated_at = excluded.updated_at, + resolved_at = excluded.resolved_at + `, + }); + + const getRow = SqlSchema.findOneOption({ + Request: GetDecisionConsultationInput, + Result: DecisionConsultationRow, + execute: ({ consultationId }) => + sql` + SELECT + consultation_id AS "consultationId", + case_id AS "caseId", + target, + status, + reason, + questions_json AS "questionsJson", + response_summary AS "responseSummary", + linked_thread_id AS "linkedThreadId", + created_at AS "createdAt", + updated_at AS "updatedAt", + resolved_at AS "resolvedAt" + FROM decision_consultations + WHERE consultation_id = ${consultationId} + `, + }); + + const listRows = SqlSchema.findAll({ + Request: ListDecisionConsultationsInput, + Result: DecisionConsultationRow, + execute: ({ caseId }) => + sql` + SELECT + consultation_id AS "consultationId", + case_id AS "caseId", + target, + status, + reason, + questions_json AS "questionsJson", + response_summary AS "responseSummary", + linked_thread_id AS "linkedThreadId", + created_at AS "createdAt", + updated_at AS "updatedAt", + resolved_at AS "resolvedAt" + FROM decision_consultations + WHERE case_id = ${caseId} + ORDER BY created_at DESC + `, + }); + + const upsert: DecisionConsultationRepositoryShape["upsert"] = (row) => + upsertRow(row).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "DecisionConsultationRepository.upsert:query", + "DecisionConsultationRepository.upsert:encodeRequest", + ), + ), + ); + + const getById: DecisionConsultationRepositoryShape["getById"] = (input) => + getRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "DecisionConsultationRepository.getById:query", + "DecisionConsultationRepository.getById:decodeRow", + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + Effect.succeed(Option.some(row as Schema.Schema.Type)), + }), + ), + ); + + const listByCaseId: DecisionConsultationRepositoryShape["listByCaseId"] = (input) => + listRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "DecisionConsultationRepository.listByCaseId:query", + "DecisionConsultationRepository.listByCaseId:decodeRows", + ), + ), + Effect.map((rows) => + rows as ReadonlyArray>, + ), + ); + + return { upsert, getById, listByCaseId } satisfies DecisionConsultationRepositoryShape; +}); + +export const DecisionConsultationRepositoryLive = Layer.effect( + DecisionConsultationRepository, + makeDecisionConsultationRepository, +); diff --git a/apps/server/src/persistence/Layers/DecisionScoreSnapshots.ts b/apps/server/src/persistence/Layers/DecisionScoreSnapshots.ts new file mode 100644 index 000000000..1b39d2f40 --- /dev/null +++ b/apps/server/src/persistence/Layers/DecisionScoreSnapshots.ts @@ -0,0 +1,78 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Schema } from "effect"; +import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; +import { + DecisionScoreSnapshotRepository, + DecisionScoreSnapshotRow, + ListDecisionScoreSnapshotsInput, + type DecisionScoreSnapshotRepositoryShape, +} from "../Services/DecisionScoreSnapshots.ts"; + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeDecisionScoreSnapshotRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const insertRow = SqlSchema.void({ + Request: DecisionScoreSnapshotRow, + execute: (row) => + sql` + INSERT INTO decision_score_snapshots ( + snapshot_id, case_id, score, analysis_json, created_at + ) VALUES ( + ${row.snapshotId}, ${row.caseId}, ${row.score}, ${row.analysisJson}, ${row.createdAt} + ) + `, + }); + + const listRows = SqlSchema.findAll({ + Request: ListDecisionScoreSnapshotsInput, + Result: DecisionScoreSnapshotRow, + execute: ({ caseId }) => + sql` + SELECT + snapshot_id AS "snapshotId", + case_id AS "caseId", + score, + analysis_json AS "analysisJson", + created_at AS "createdAt" + FROM decision_score_snapshots + WHERE case_id = ${caseId} + ORDER BY created_at DESC + `, + }); + + const insert: DecisionScoreSnapshotRepositoryShape["insert"] = (row) => + insertRow(row).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "DecisionScoreSnapshotRepository.insert:query", + "DecisionScoreSnapshotRepository.insert:encodeRequest", + ), + ), + ); + + const listByCaseId: DecisionScoreSnapshotRepositoryShape["listByCaseId"] = (input) => + listRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "DecisionScoreSnapshotRepository.listByCaseId:query", + "DecisionScoreSnapshotRepository.listByCaseId:decodeRows", + ), + ), + Effect.map((rows) => rows as ReadonlyArray>), + ); + + return { insert, listByCaseId } satisfies DecisionScoreSnapshotRepositoryShape; +}); + +export const DecisionScoreSnapshotRepositoryLive = Layer.effect( + DecisionScoreSnapshotRepository, + makeDecisionScoreSnapshotRepository, +); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 7f244932f..b50769b57 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -32,6 +32,7 @@ import Migration0017 from "./Migrations/017_EnvironmentVariables.ts"; import Migration0018 from "./Migrations/018_ProjectionThreadsGithubRef.ts"; import Migration0019 from "./Migrations/019_SmeKnowledgeBase.ts"; import Migration0020 from "./Migrations/020_SmeConversationProviderAuth.ts"; +import Migration0022 from "./Migrations/022_DecisionWorkspace.ts"; import { Effect } from "effect"; /** @@ -65,6 +66,7 @@ const loader = Migrator.fromRecord({ "18_ProjectionThreadsGithubRef": Migration0018, "19_SmeKnowledgeBase": Migration0019, "20_SmeConversationProviderAuth": Migration0020, + "22_DecisionWorkspace": Migration0022, }); /** diff --git a/apps/server/src/persistence/Migrations/022_DecisionWorkspace.ts b/apps/server/src/persistence/Migrations/022_DecisionWorkspace.ts new file mode 100644 index 000000000..2e17b7128 --- /dev/null +++ b/apps/server/src/persistence/Migrations/022_DecisionWorkspace.ts @@ -0,0 +1,62 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS decision_cases ( + case_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + cwd TEXT NOT NULL, + source_kind TEXT NOT NULL, + source_id TEXT NOT NULL, + title TEXT NOT NULL, + conflict_kind TEXT NOT NULL, + linked_thread_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_decision_cases_cwd + ON decision_cases(cwd, updated_at) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS decision_consultations ( + consultation_id TEXT PRIMARY KEY, + case_id TEXT NOT NULL, + target TEXT NOT NULL, + status TEXT NOT NULL, + reason TEXT NOT NULL, + questions_json TEXT NOT NULL, + response_summary TEXT, + linked_thread_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + resolved_at TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_decision_consultations_case + ON decision_consultations(case_id, created_at) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS decision_score_snapshots ( + snapshot_id TEXT PRIMARY KEY, + case_id TEXT NOT NULL, + score INTEGER NOT NULL, + analysis_json TEXT NOT NULL, + created_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_decision_score_snapshots_case + ON decision_score_snapshots(case_id, created_at) + `; +}); diff --git a/apps/server/src/persistence/Services/DecisionCases.ts b/apps/server/src/persistence/Services/DecisionCases.ts new file mode 100644 index 000000000..66ef0fee6 --- /dev/null +++ b/apps/server/src/persistence/Services/DecisionCases.ts @@ -0,0 +1,51 @@ +import { + DecisionCaseId, + DecisionConflictKind, + DecisionSource, + IsoDateTime, + ProjectId, + ThreadId, + TrimmedNonEmptyString, +} from "@okcode/contracts"; +import { Option, Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const DecisionCaseRow = Schema.Struct({ + caseId: DecisionCaseId, + projectId: ProjectId, + cwd: TrimmedNonEmptyString, + sourceKind: DecisionSource, + sourceId: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + conflictKind: DecisionConflictKind, + linkedThreadId: Schema.NullOr(ThreadId), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type DecisionCaseRow = typeof DecisionCaseRow.Type; + +export const GetDecisionCaseInput = Schema.Struct({ + caseId: DecisionCaseId, +}); +export type GetDecisionCaseInput = typeof GetDecisionCaseInput.Type; + +export const ListDecisionCasesInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, +}); +export type ListDecisionCasesInput = typeof ListDecisionCasesInput.Type; + +export interface DecisionCaseRepositoryShape { + readonly upsert: (row: DecisionCaseRow) => Effect.Effect; + readonly getById: ( + input: GetDecisionCaseInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly listByCwd: ( + input: ListDecisionCasesInput, + ) => Effect.Effect, ProjectionRepositoryError>; +} + +export class DecisionCaseRepository extends ServiceMap.Service< + DecisionCaseRepository, + DecisionCaseRepositoryShape +>()("okcode/persistence/Services/DecisionCases/DecisionCaseRepository") {} diff --git a/apps/server/src/persistence/Services/DecisionConsultations.ts b/apps/server/src/persistence/Services/DecisionConsultations.ts new file mode 100644 index 000000000..aa8302b0b --- /dev/null +++ b/apps/server/src/persistence/Services/DecisionConsultations.ts @@ -0,0 +1,51 @@ +import { + DecisionCaseId, + DecisionConsultationId, + DecisionConsultationStatus, + DecisionConsultationTarget, + IsoDateTime, + ThreadId, +} from "@okcode/contracts"; +import { Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const DecisionConsultationRow = Schema.Struct({ + consultationId: DecisionConsultationId, + caseId: DecisionCaseId, + target: DecisionConsultationTarget, + status: DecisionConsultationStatus, + reason: Schema.String, + questionsJson: Schema.String, + responseSummary: Schema.NullOr(Schema.String), + linkedThreadId: Schema.NullOr(ThreadId), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + resolvedAt: Schema.NullOr(IsoDateTime), +}); +export type DecisionConsultationRow = typeof DecisionConsultationRow.Type; + +export const GetDecisionConsultationInput = Schema.Struct({ + consultationId: DecisionConsultationId, +}); +export type GetDecisionConsultationInput = typeof GetDecisionConsultationInput.Type; + +export const ListDecisionConsultationsInput = Schema.Struct({ + caseId: DecisionCaseId, +}); +export type ListDecisionConsultationsInput = typeof ListDecisionConsultationsInput.Type; + +export interface DecisionConsultationRepositoryShape { + readonly upsert: (row: DecisionConsultationRow) => Effect.Effect; + readonly getById: ( + input: GetDecisionConsultationInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly listByCaseId: ( + input: ListDecisionConsultationsInput, + ) => Effect.Effect, ProjectionRepositoryError>; +} + +export class DecisionConsultationRepository extends ServiceMap.Service< + DecisionConsultationRepository, + DecisionConsultationRepositoryShape +>()("okcode/persistence/Services/DecisionConsultations/DecisionConsultationRepository") {} diff --git a/apps/server/src/persistence/Services/DecisionScoreSnapshots.ts b/apps/server/src/persistence/Services/DecisionScoreSnapshots.ts new file mode 100644 index 000000000..72af98b65 --- /dev/null +++ b/apps/server/src/persistence/Services/DecisionScoreSnapshots.ts @@ -0,0 +1,32 @@ +import { DecisionCaseId, IsoDateTime, NonNegativeInt, TrimmedNonEmptyString } from "@okcode/contracts"; +import { Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const DecisionScoreSnapshotRow = Schema.Struct({ + snapshotId: TrimmedNonEmptyString, + caseId: DecisionCaseId, + score: NonNegativeInt, + analysisJson: Schema.String, + createdAt: IsoDateTime, +}); +export type DecisionScoreSnapshotRow = typeof DecisionScoreSnapshotRow.Type; + +export const ListDecisionScoreSnapshotsInput = Schema.Struct({ + caseId: DecisionCaseId, +}); +export type ListDecisionScoreSnapshotsInput = typeof ListDecisionScoreSnapshotsInput.Type; + +export interface DecisionScoreSnapshotRepositoryShape { + readonly insert: ( + row: DecisionScoreSnapshotRow, + ) => Effect.Effect; + readonly listByCaseId: ( + input: ListDecisionScoreSnapshotsInput, + ) => Effect.Effect, ProjectionRepositoryError>; +} + +export class DecisionScoreSnapshotRepository extends ServiceMap.Service< + DecisionScoreSnapshotRepository, + DecisionScoreSnapshotRepositoryShape +>()("okcode/persistence/Services/DecisionScoreSnapshots/DecisionScoreSnapshotRepository") {} diff --git a/packages/contracts/src/decision.ts b/packages/contracts/src/decision.ts new file mode 100644 index 000000000..7d34dc88c --- /dev/null +++ b/packages/contracts/src/decision.ts @@ -0,0 +1,300 @@ +import { Schema } from "effect"; +import { + IsoDateTime, + NonNegativeInt, + ProjectId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas"; +import { OrchestrationThread, ProviderUserInputAnswers } from "./orchestration"; +import { + PrConflictAnalysis, + PrConflictApplyResult, + PrReviewConfig, + PrReviewDashboardResult, + PrReviewPatchResult, + PrReviewSummary, + PrWorkflowStepResolution, +} from "./prReview"; + +export const DECISION_WS_METHODS = { + listCases: "decision.listCases", + getWorkspace: "decision.getWorkspace", + reanalyze: "decision.reanalyze", + requestConsultation: "decision.requestConsultation", + respondConsultation: "decision.respondConsultation", + executeRecommendation: "decision.executeRecommendation", +} as const; + +export const DECISION_WS_CHANNELS = { + updated: "decision.updated", +} as const; + +export const DecisionCaseId = TrimmedNonEmptyString; +export type DecisionCaseId = typeof DecisionCaseId.Type; + +export const DecisionConsultationId = TrimmedNonEmptyString; +export type DecisionConsultationId = typeof DecisionConsultationId.Type; + +export const DecisionRecommendationId = TrimmedNonEmptyString; +export type DecisionRecommendationId = typeof DecisionRecommendationId.Type; + +export const DecisionSource = Schema.Literals(["pr", "thread", "manual_brief"]); +export type DecisionSource = typeof DecisionSource.Type; + +export const DecisionConflictKind = Schema.Literals([ + "merge_conflict", + "workflow_blocker", + "review_conflict", + "intent_ambiguity", + "policy_conflict", +]); +export type DecisionConflictKind = typeof DecisionConflictKind.Type; + +export const DecisionConsultationTarget = Schema.Literals(["operator", "orchestrator"]); +export type DecisionConsultationTarget = typeof DecisionConsultationTarget.Type; + +export const DecisionConsultationStatus = Schema.Literals([ + "not_requested", + "awaiting_operator", + "awaiting_orchestrator", + "resolved", + "superseded", +]); +export type DecisionConsultationStatus = typeof DecisionConsultationStatus.Type; + +export const DecisionRiskTier = Schema.Literals(["low", "medium", "high"]); +export type DecisionRiskTier = typeof DecisionRiskTier.Type; + +export const DecisionFactorId = Schema.Literals([ + "contextCompleteness", + "evidenceQuality", + "sourceAgreement", + "policyAlignment", + "safetyAndReversibility", + "executionReadiness", +]); +export type DecisionFactorId = typeof DecisionFactorId.Type; + +export const DecisionEvidenceSource = Schema.Struct({ + id: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + source: DecisionSource, + kind: TrimmedNonEmptyString, + capturedAt: IsoDateTime, + freshness: Schema.Literals(["fresh", "stale", "derived"]), + usedInDecision: Schema.Boolean, +}); +export type DecisionEvidenceSource = typeof DecisionEvidenceSource.Type; + +export const DecisionContextRequirement = Schema.Struct({ + id: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + satisfied: Schema.Boolean, + whyItMatters: TrimmedNonEmptyString, + howToProvideIt: TrimmedNonEmptyString, + evidenceIds: Schema.Array(TrimmedNonEmptyString), +}); +export type DecisionContextRequirement = typeof DecisionContextRequirement.Type; + +export const DecisionPrincipleResult = Schema.Struct({ + id: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + passed: Schema.Boolean, + blocking: Schema.Boolean, + rationale: TrimmedNonEmptyString, + evidenceIds: Schema.Array(TrimmedNonEmptyString), +}); +export type DecisionPrincipleResult = typeof DecisionPrincipleResult.Type; + +export const DecisionConfidenceFactor = Schema.Struct({ + id: DecisionFactorId, + label: TrimmedNonEmptyString, + score: NonNegativeInt.pipe(Schema.clamp(0, 100)), + weight: Schema.Number, + weightedPoints: Schema.Number, + why: TrimmedNonEmptyString, + missingInputs: Schema.Array(TrimmedNonEmptyString), + evidenceIds: Schema.Array(TrimmedNonEmptyString), +}); +export type DecisionConfidenceFactor = typeof DecisionConfidenceFactor.Type; + +export const DecisionNextContextHint = Schema.Struct({ + label: TrimmedNonEmptyString, + whyItMatters: TrimmedNonEmptyString, + howToProvideIt: TrimmedNonEmptyString, + estimatedScoreGain: NonNegativeInt, + appliesTo: Schema.Array(DecisionFactorId), +}); +export type DecisionNextContextHint = typeof DecisionNextContextHint.Type; + +export const DecisionConfidenceAnalysis = Schema.Struct({ + score: NonNegativeInt.pipe(Schema.clamp(0, 100)), + riskTier: DecisionRiskTier, + autoExecuteEligible: Schema.Boolean, + scoreDelta: Schema.Int, + contextCoverageNumerator: NonNegativeInt, + contextCoverageDenominator: NonNegativeInt, + sourceAgreementNumerator: NonNegativeInt, + sourceAgreementDenominator: NonNegativeInt, + policyPassNumerator: NonNegativeInt, + policyPassDenominator: NonNegativeInt, + factors: Schema.Array(DecisionConfidenceFactor), + nextContextHints: Schema.Array(DecisionNextContextHint), +}); +export type DecisionConfidenceAnalysis = typeof DecisionConfidenceAnalysis.Type; + +export const DecisionRecommendation = Schema.Struct({ + id: DecisionRecommendationId, + label: TrimmedNonEmptyString, + rationale: TrimmedNonEmptyString, + executable: Schema.Boolean, + blockedReason: Schema.NullOr(TrimmedNonEmptyString), + preview: Schema.String, + source: DecisionSource, +}); +export type DecisionRecommendation = typeof DecisionRecommendation.Type; + +export const DecisionConsultationQuestionOption = Schema.Struct({ + label: TrimmedNonEmptyString, + description: TrimmedNonEmptyString, +}); +export type DecisionConsultationQuestionOption = typeof DecisionConsultationQuestionOption.Type; + +export const DecisionConsultationQuestion = Schema.Struct({ + id: TrimmedNonEmptyString, + header: TrimmedNonEmptyString, + question: TrimmedNonEmptyString, + options: Schema.Array(DecisionConsultationQuestionOption), +}); +export type DecisionConsultationQuestion = typeof DecisionConsultationQuestion.Type; + +export const DecisionConsultation = Schema.Struct({ + id: DecisionConsultationId, + caseId: DecisionCaseId, + target: DecisionConsultationTarget, + status: DecisionConsultationStatus, + reason: TrimmedNonEmptyString, + questions: Schema.Array(DecisionConsultationQuestion), + responseSummary: Schema.NullOr(Schema.String), + linkedThreadId: Schema.NullOr(ThreadId), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + resolvedAt: Schema.NullOr(IsoDateTime), +}); +export type DecisionConsultation = typeof DecisionConsultation.Type; + +export const DecisionCaseSummary = Schema.Struct({ + id: DecisionCaseId, + projectId: ProjectId, + cwd: TrimmedNonEmptyString, + sourceKind: DecisionSource, + sourceId: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + subtitle: Schema.String, + conflictKind: DecisionConflictKind, + score: NonNegativeInt.pipe(Schema.clamp(0, 100)), + riskTier: DecisionRiskTier, + consultationStatus: DecisionConsultationStatus, + updatedAt: IsoDateTime, +}); +export type DecisionCaseSummary = typeof DecisionCaseSummary.Type; + +export const DecisionContextPack = Schema.Struct({ + sourceKind: DecisionSource, + sourceId: TrimmedNonEmptyString, + prSummary: Schema.optional(PrReviewSummary), + prDashboard: Schema.optional(PrReviewDashboardResult), + prPatch: Schema.optional(PrReviewPatchResult), + prConfig: Schema.optional(PrReviewConfig), + prConflicts: Schema.optional(PrConflictAnalysis), + workflowSteps: Schema.optional(Schema.Array(PrWorkflowStepResolution)), + thread: Schema.optional(OrchestrationThread), + manualBrief: Schema.optional(Schema.String), + evidence: Schema.Array(DecisionEvidenceSource), + contextRequirements: Schema.Array(DecisionContextRequirement), + linkedThreadId: Schema.NullOr(ThreadId), +}); +export type DecisionContextPack = typeof DecisionContextPack.Type; + +export const DecisionCase = Schema.Struct({ + id: DecisionCaseId, + projectId: ProjectId, + cwd: TrimmedNonEmptyString, + sourceKind: DecisionSource, + sourceId: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + conflictKind: DecisionConflictKind, + linkedThreadId: Schema.NullOr(ThreadId), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type DecisionCase = typeof DecisionCase.Type; + +export const DecisionWorkspace = Schema.Struct({ + case: DecisionCase, + summary: DecisionCaseSummary, + contextPack: DecisionContextPack, + principles: Schema.Array(DecisionPrincipleResult), + confidence: DecisionConfidenceAnalysis, + recommendations: Schema.Array(DecisionRecommendation), + consultations: Schema.Array(DecisionConsultation), +}); +export type DecisionWorkspace = typeof DecisionWorkspace.Type; + +export const DecisionExecutionResult = Schema.Struct({ + caseId: DecisionCaseId, + recommendationId: DecisionRecommendationId, + executed: Schema.Boolean, + summary: TrimmedNonEmptyString, + appliedConflictResult: Schema.optional(PrConflictApplyResult), + updatedAt: IsoDateTime, +}); +export type DecisionExecutionResult = typeof DecisionExecutionResult.Type; + +export const DecisionListCasesInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + sourceKind: Schema.optional(DecisionSource), + sourceId: Schema.optional(TrimmedNonEmptyString), +}); +export type DecisionListCasesInput = typeof DecisionListCasesInput.Type; + +export const DecisionGetWorkspaceInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + caseId: DecisionCaseId, +}); +export type DecisionGetWorkspaceInput = typeof DecisionGetWorkspaceInput.Type; + +export const DecisionReanalyzeInput = DecisionGetWorkspaceInput; +export type DecisionReanalyzeInput = typeof DecisionReanalyzeInput.Type; + +export const DecisionRequestConsultationInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + caseId: DecisionCaseId, + target: DecisionConsultationTarget, + reason: TrimmedNonEmptyString, + questions: Schema.optional(Schema.Array(DecisionConsultationQuestion)), +}); +export type DecisionRequestConsultationInput = typeof DecisionRequestConsultationInput.Type; + +export const DecisionRespondConsultationInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + consultationId: DecisionConsultationId, + answers: ProviderUserInputAnswers, + resolution: TrimmedNonEmptyString, +}); +export type DecisionRespondConsultationInput = typeof DecisionRespondConsultationInput.Type; + +export const DecisionExecuteRecommendationInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + caseId: DecisionCaseId, + recommendationId: DecisionRecommendationId, +}); +export type DecisionExecuteRecommendationInput = typeof DecisionExecuteRecommendationInput.Type; + +export const DecisionUpdatedPayload = Schema.Struct({ + cwd: TrimmedNonEmptyString, + caseId: DecisionCaseId, + updatedAt: IsoDateTime, +}); +export type DecisionUpdatedPayload = typeof DecisionUpdatedPayload.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 4c3f0484e..2ad7d5db8 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -18,3 +18,4 @@ export * from "./environment"; export * from "./skill"; export * from "./skillCatalog"; export * from "./sme"; +export * from "./decision"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 9e33b7c44..710d79940 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -123,6 +123,17 @@ import type { OrchestrationOverviewSnapshot, OrchestrationThread, } from "./orchestration"; +import type { + DecisionCaseSummary, + DecisionExecuteRecommendationInput, + DecisionExecutionResult, + DecisionGetWorkspaceInput, + DecisionListCasesInput, + DecisionRequestConsultationInput, + DecisionRespondConsultationInput, + DecisionUpdatedPayload, + DecisionWorkspace, +} from "./decision"; import type { SmeConversation, SmeCreateConversationInput, @@ -422,6 +433,17 @@ export interface NativeApi { callback: (payload: PrReviewRepoConfigUpdatedPayload) => void, ) => () => void; }; + decision: { + listCases: (input: DecisionListCasesInput) => Promise>; + getWorkspace: (input: DecisionGetWorkspaceInput) => Promise; + reanalyze: (input: DecisionGetWorkspaceInput) => Promise; + requestConsultation: (input: DecisionRequestConsultationInput) => Promise; + respondConsultation: (input: DecisionRespondConsultationInput) => Promise; + executeRecommendation: ( + input: DecisionExecuteRecommendationInput, + ) => Promise; + onUpdated: (callback: (payload: DecisionUpdatedPayload) => void) => () => void; + }; skills: { list: (input?: SkillListInput) => Promise; catalog: (input?: SkillCatalogInput) => Promise; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 68af83955..8d35bd821 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -45,6 +45,16 @@ import { PrReviewUserPreviewInput, PrSubmitReviewInput, } from "./prReview"; +import { + DECISION_WS_CHANNELS, + DECISION_WS_METHODS, + DecisionExecuteRecommendationInput, + DecisionGetWorkspaceInput, + DecisionListCasesInput, + DecisionRequestConsultationInput, + DecisionRespondConsultationInput, + DecisionUpdatedPayload, +} from "./decision"; import { TerminalClearInput, TerminalCloseInput, @@ -163,6 +173,14 @@ export const WS_METHODS = { prReviewRunWorkflowStep: "prReview.runWorkflowStep", prReviewSubmitReview: "prReview.submitReview", + // Decision workspace methods + decisionListCases: DECISION_WS_METHODS.listCases, + decisionGetWorkspace: DECISION_WS_METHODS.getWorkspace, + decisionReanalyze: DECISION_WS_METHODS.reanalyze, + decisionRequestConsultation: DECISION_WS_METHODS.requestConsultation, + decisionRespondConsultation: DECISION_WS_METHODS.respondConsultation, + decisionExecuteRecommendation: DECISION_WS_METHODS.executeRecommendation, + // Terminal methods terminalOpen: "terminal.open", terminalWrite: "terminal.write", @@ -230,6 +248,7 @@ export const WS_CHANNELS = { gitActionProgress: "git.actionProgress", prReviewSyncUpdated: "prReview.syncUpdated", prReviewRepoConfigUpdated: "prReview.repoConfigUpdated", + decisionUpdated: DECISION_WS_CHANNELS.updated, terminalEvent: "terminal.event", serverWelcome: "server.welcome", serverConfigUpdated: "server.configUpdated", @@ -313,6 +332,14 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.prReviewRunWorkflowStep, PrReviewRunWorkflowStepInput), tagRequestBody(WS_METHODS.prReviewSubmitReview, PrSubmitReviewInput), + // Decision workspace methods + tagRequestBody(WS_METHODS.decisionListCases, DecisionListCasesInput), + tagRequestBody(WS_METHODS.decisionGetWorkspace, DecisionGetWorkspaceInput), + tagRequestBody(WS_METHODS.decisionReanalyze, DecisionGetWorkspaceInput), + tagRequestBody(WS_METHODS.decisionRequestConsultation, DecisionRequestConsultationInput), + tagRequestBody(WS_METHODS.decisionRespondConsultation, DecisionRespondConsultationInput), + tagRequestBody(WS_METHODS.decisionExecuteRecommendation, DecisionExecuteRecommendationInput), + // Terminal methods tagRequestBody(WS_METHODS.terminalOpen, TerminalOpenInput), tagRequestBody(WS_METHODS.terminalWrite, TerminalWriteInput), @@ -420,6 +447,7 @@ export interface WsPushPayloadByChannel { readonly [WS_CHANNELS.gitActionProgress]: typeof GitActionProgressEvent.Type; readonly [WS_CHANNELS.prReviewSyncUpdated]: typeof PrReviewSyncUpdatedPayload.Type; readonly [WS_CHANNELS.prReviewRepoConfigUpdated]: typeof PrReviewRepoConfigUpdatedPayload.Type; + readonly [WS_CHANNELS.decisionUpdated]: typeof DecisionUpdatedPayload.Type; readonly [WS_CHANNELS.terminalEvent]: typeof TerminalEvent.Type; readonly [WS_CHANNELS.projectFileTreeChanged]: typeof ProjectFileTreeChangedPayload.Type; readonly [ORCHESTRATION_WS_CHANNELS.domainEvent]: OrchestrationEvent; @@ -457,6 +485,10 @@ export const WsPushPrReviewRepoConfigUpdated = makeWsPushSchema( WS_CHANNELS.prReviewRepoConfigUpdated, PrReviewRepoConfigUpdatedPayload, ); +export const WsPushDecisionUpdated = makeWsPushSchema( + WS_CHANNELS.decisionUpdated, + DecisionUpdatedPayload, +); export const WsPushTerminalEvent = makeWsPushSchema(WS_CHANNELS.terminalEvent, TerminalEvent); export const WsPushProjectFileTreeChanged = makeWsPushSchema( WS_CHANNELS.projectFileTreeChanged, @@ -475,6 +507,7 @@ export const WsPushChannelSchema = Schema.Literals([ WS_CHANNELS.gitActionProgress, WS_CHANNELS.prReviewSyncUpdated, WS_CHANNELS.prReviewRepoConfigUpdated, + WS_CHANNELS.decisionUpdated, WS_CHANNELS.serverWelcome, WS_CHANNELS.serverConfigUpdated, WS_CHANNELS.terminalEvent, @@ -490,6 +523,7 @@ export const WsPush = Schema.Union([ WsPushGitActionProgress, WsPushPrReviewSyncUpdated, WsPushPrReviewRepoConfigUpdated, + WsPushDecisionUpdated, WsPushTerminalEvent, WsPushProjectFileTreeChanged, WsPushOrchestrationDomainEvent,