Skip to content

Commit f79254a

Browse files
authored
Add decision workspace contracts and WS wiring (#411)
* Add decision workspace contracts - Add schema and IPC types for decision cases and workspace - Wire new decision WebSocket methods and update channel events * Add decision workspace persistence and service layers - Introduce decision workspace domain services and error type - Add persistence models, repositories, and migration for cases, consultations, and score snapshots - Wire the new migration into the persistence loader
1 parent e0a7269 commit f79254a

18 files changed

Lines changed: 1113 additions & 0 deletions

apps/server/src/decision/Errors.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Schema } from "effect";
2+
3+
export class DecisionWorkspaceError extends Schema.TaggedErrorClass<DecisionWorkspaceError>()(
4+
"DecisionWorkspaceError",
5+
{
6+
operation: Schema.String,
7+
detail: Schema.String,
8+
cause: Schema.optional(Schema.Defect),
9+
},
10+
) {
11+
override get message(): string {
12+
return `Decision workspace failed in ${this.operation}: ${this.detail}`;
13+
}
14+
}
15+
16+
export type DecisionWorkspaceServiceError = DecisionWorkspaceError;
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type {
2+
DecisionCase,
3+
DecisionConsultation,
4+
DecisionConsultationQuestion,
5+
DecisionContextPack,
6+
} from "@okcode/contracts";
7+
import { ServiceMap } from "effect";
8+
import type { Effect } from "effect";
9+
import type { DecisionPolicyDefinition } from "./DecisionPolicy.ts";
10+
import type { DecisionWorkspaceServiceError } from "../Errors.ts";
11+
12+
export interface DecisionConsultationServiceShape {
13+
readonly request: (input: {
14+
readonly decisionCase: DecisionCase;
15+
readonly target: "operator" | "orchestrator";
16+
readonly reason: string;
17+
readonly questions: ReadonlyArray<DecisionConsultationQuestion>;
18+
readonly contextPack: DecisionContextPack;
19+
readonly policy: DecisionPolicyDefinition;
20+
}) => Effect.Effect<DecisionConsultation, DecisionWorkspaceServiceError>;
21+
readonly respond: (input: {
22+
readonly consultationId: string;
23+
readonly resolution: string;
24+
}) => Effect.Effect<DecisionConsultation, DecisionWorkspaceServiceError>;
25+
}
26+
27+
export class DecisionConsultationService extends ServiceMap.Service<
28+
DecisionConsultationService,
29+
DecisionConsultationServiceShape
30+
>()("okcode/decision/Services/DecisionConsultationService") {}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type {
2+
DecisionCase,
3+
DecisionContextPack,
4+
DecisionPrincipleResult,
5+
DecisionRecommendation,
6+
} from "@okcode/contracts";
7+
import { ServiceMap } from "effect";
8+
import type { Effect } from "effect";
9+
import type { DecisionPolicyDefinition } from "./DecisionPolicy.ts";
10+
import type { DecisionWorkspaceServiceError } from "../Errors.ts";
11+
12+
export interface DecisionContextPackBuilderShape {
13+
readonly listAutoCases: (input: {
14+
readonly cwd: string;
15+
}) => Effect.Effect<ReadonlyArray<DecisionCase>, DecisionWorkspaceServiceError>;
16+
readonly buildCaseArtifacts: (input: {
17+
readonly decisionCase: DecisionCase;
18+
readonly policy: DecisionPolicyDefinition;
19+
}) => Effect.Effect<
20+
{
21+
readonly contextPack: DecisionContextPack;
22+
readonly principles: ReadonlyArray<DecisionPrincipleResult>;
23+
readonly recommendations: ReadonlyArray<DecisionRecommendation>;
24+
},
25+
DecisionWorkspaceServiceError
26+
>;
27+
}
28+
29+
export class DecisionContextPackBuilder extends ServiceMap.Service<
30+
DecisionContextPackBuilder,
31+
DecisionContextPackBuilderShape
32+
>()("okcode/decision/Services/DecisionContextPackBuilder") {}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { DecisionConflictKind } from "@okcode/contracts";
2+
import { ServiceMap } from "effect";
3+
import type { Effect } from "effect";
4+
import type { DecisionWorkspaceServiceError } from "../Errors.ts";
5+
6+
export interface DecisionPolicyDefinition {
7+
readonly version: string;
8+
readonly principles: ReadonlyArray<{
9+
readonly id: string;
10+
readonly label: string;
11+
readonly blocking: boolean;
12+
}>;
13+
readonly executionThresholds: {
14+
readonly autoExecuteScore: number;
15+
readonly minimumContextCompleteness: number;
16+
readonly minimumPolicyAlignment: number;
17+
};
18+
readonly requiredContextByKind: Readonly<Record<DecisionConflictKind, ReadonlyArray<string>>>;
19+
readonly consultationDefaults: {
20+
readonly orchestratorMinScore: number;
21+
readonly operatorMaxScore: number;
22+
};
23+
}
24+
25+
export interface DecisionPolicyShape {
26+
readonly getPolicy: (input: {
27+
readonly cwd: string;
28+
}) => Effect.Effect<DecisionPolicyDefinition, DecisionWorkspaceServiceError>;
29+
}
30+
31+
export class DecisionPolicy extends ServiceMap.Service<DecisionPolicy, DecisionPolicyShape>()(
32+
"okcode/decision/Services/DecisionPolicy",
33+
) {}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type {
2+
DecisionCase,
3+
DecisionConfidenceAnalysis,
4+
DecisionConsultation,
5+
DecisionConsultationQuestion,
6+
DecisionConsultationStatus,
7+
DecisionConsultationTarget,
8+
} from "@okcode/contracts";
9+
import { ServiceMap } from "effect";
10+
import type { Effect } from "effect";
11+
import type { DecisionWorkspaceServiceError } from "../Errors.ts";
12+
13+
export interface DecisionProjectionShape {
14+
readonly upsertCase: (input: DecisionCase) => Effect.Effect<void, DecisionWorkspaceServiceError>;
15+
readonly getCase: (input: {
16+
readonly caseId: string;
17+
}) => Effect.Effect<DecisionCase | null, DecisionWorkspaceServiceError>;
18+
readonly listCasesByCwd: (input: {
19+
readonly cwd: string;
20+
}) => Effect.Effect<ReadonlyArray<DecisionCase>, DecisionWorkspaceServiceError>;
21+
readonly upsertConsultation: (input: {
22+
readonly consultation: DecisionConsultation;
23+
readonly questions: ReadonlyArray<DecisionConsultationQuestion>;
24+
}) => Effect.Effect<void, DecisionWorkspaceServiceError>;
25+
readonly getConsultation: (input: {
26+
readonly consultationId: string;
27+
}) => Effect.Effect<
28+
{ consultation: DecisionConsultation; questions: ReadonlyArray<DecisionConsultationQuestion> } | null,
29+
DecisionWorkspaceServiceError
30+
>;
31+
readonly listConsultationsByCaseId: (input: {
32+
readonly caseId: string;
33+
}) => Effect.Effect<ReadonlyArray<DecisionConsultation>, DecisionWorkspaceServiceError>;
34+
readonly appendScoreSnapshot: (input: {
35+
readonly caseId: string;
36+
readonly analysis: DecisionConfidenceAnalysis;
37+
}) => Effect.Effect<void, DecisionWorkspaceServiceError>;
38+
readonly listScoreSnapshots: (input: {
39+
readonly caseId: string;
40+
}) => Effect.Effect<ReadonlyArray<DecisionConfidenceAnalysis>, DecisionWorkspaceServiceError>;
41+
readonly createConsultation: (input: {
42+
readonly caseId: string;
43+
readonly target: DecisionConsultationTarget;
44+
readonly status: DecisionConsultationStatus;
45+
readonly reason: string;
46+
readonly questions: ReadonlyArray<DecisionConsultationQuestion>;
47+
readonly linkedThreadId: string | null;
48+
readonly responseSummary?: string | null;
49+
readonly resolvedAt?: string | null;
50+
}) => Effect.Effect<DecisionConsultation, DecisionWorkspaceServiceError>;
51+
}
52+
53+
export class DecisionProjection extends ServiceMap.Service<
54+
DecisionProjection,
55+
DecisionProjectionShape
56+
>()("okcode/decision/Services/DecisionProjection") {}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type {
2+
DecisionCaseSummary,
3+
DecisionExecuteRecommendationInput,
4+
DecisionExecutionResult,
5+
DecisionGetWorkspaceInput,
6+
DecisionListCasesInput,
7+
DecisionRequestConsultationInput,
8+
DecisionRespondConsultationInput,
9+
DecisionWorkspace as DecisionWorkspaceResult,
10+
} from "@okcode/contracts";
11+
import { ServiceMap } from "effect";
12+
import type { Effect } from "effect";
13+
import type { DecisionWorkspaceServiceError } from "../Errors.ts";
14+
15+
export interface DecisionWorkspaceShape {
16+
readonly listCases: (
17+
input: DecisionListCasesInput,
18+
) => Effect.Effect<ReadonlyArray<DecisionCaseSummary>, DecisionWorkspaceServiceError>;
19+
readonly getWorkspace: (
20+
input: DecisionGetWorkspaceInput,
21+
) => Effect.Effect<DecisionWorkspaceResult, DecisionWorkspaceServiceError>;
22+
readonly reanalyze: (
23+
input: DecisionGetWorkspaceInput,
24+
) => Effect.Effect<DecisionWorkspaceResult, DecisionWorkspaceServiceError>;
25+
readonly requestConsultation: (
26+
input: DecisionRequestConsultationInput,
27+
) => Effect.Effect<DecisionWorkspaceResult, DecisionWorkspaceServiceError>;
28+
readonly respondConsultation: (
29+
input: DecisionRespondConsultationInput,
30+
) => Effect.Effect<DecisionWorkspaceResult, DecisionWorkspaceServiceError>;
31+
readonly executeRecommendation: (
32+
input: DecisionExecuteRecommendationInput,
33+
) => Effect.Effect<DecisionExecutionResult, DecisionWorkspaceServiceError>;
34+
}
35+
36+
export class DecisionWorkspace extends ServiceMap.Service<
37+
DecisionWorkspace,
38+
DecisionWorkspaceShape
39+
>()("okcode/decision/Services/DecisionWorkspace") {}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import * as SqlClient from "effect/unstable/sql/SqlClient";
2+
import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3+
import { Effect, Layer, Option, Schema } from "effect";
4+
import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts";
5+
import {
6+
DecisionCaseRepository,
7+
DecisionCaseRow,
8+
GetDecisionCaseInput,
9+
ListDecisionCasesInput,
10+
type DecisionCaseRepositoryShape,
11+
} from "../Services/DecisionCases.ts";
12+
13+
function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
14+
return (cause: unknown) =>
15+
Schema.isSchemaError(cause)
16+
? toPersistenceDecodeError(decodeOperation)(cause)
17+
: toPersistenceSqlError(sqlOperation)(cause);
18+
}
19+
20+
const makeDecisionCaseRepository = Effect.gen(function* () {
21+
const sql = yield* SqlClient.SqlClient;
22+
23+
const upsertRow = SqlSchema.void({
24+
Request: DecisionCaseRow,
25+
execute: (row) =>
26+
sql`
27+
INSERT INTO decision_cases (
28+
case_id, project_id, cwd, source_kind, source_id, title, conflict_kind,
29+
linked_thread_id, created_at, updated_at
30+
) VALUES (
31+
${row.caseId}, ${row.projectId}, ${row.cwd}, ${row.sourceKind}, ${row.sourceId}, ${row.title},
32+
${row.conflictKind}, ${row.linkedThreadId}, ${row.createdAt}, ${row.updatedAt}
33+
)
34+
ON CONFLICT (case_id)
35+
DO UPDATE SET
36+
project_id = excluded.project_id,
37+
cwd = excluded.cwd,
38+
source_kind = excluded.source_kind,
39+
source_id = excluded.source_id,
40+
title = excluded.title,
41+
conflict_kind = excluded.conflict_kind,
42+
linked_thread_id = excluded.linked_thread_id,
43+
updated_at = excluded.updated_at
44+
`,
45+
});
46+
47+
const getRow = SqlSchema.findOneOption({
48+
Request: GetDecisionCaseInput,
49+
Result: DecisionCaseRow,
50+
execute: ({ caseId }) =>
51+
sql`
52+
SELECT
53+
case_id AS "caseId",
54+
project_id AS "projectId",
55+
cwd,
56+
source_kind AS "sourceKind",
57+
source_id AS "sourceId",
58+
title,
59+
conflict_kind AS "conflictKind",
60+
linked_thread_id AS "linkedThreadId",
61+
created_at AS "createdAt",
62+
updated_at AS "updatedAt"
63+
FROM decision_cases
64+
WHERE case_id = ${caseId}
65+
`,
66+
});
67+
68+
const listRows = SqlSchema.findAll({
69+
Request: ListDecisionCasesInput,
70+
Result: DecisionCaseRow,
71+
execute: ({ cwd }) =>
72+
sql`
73+
SELECT
74+
case_id AS "caseId",
75+
project_id AS "projectId",
76+
cwd,
77+
source_kind AS "sourceKind",
78+
source_id AS "sourceId",
79+
title,
80+
conflict_kind AS "conflictKind",
81+
linked_thread_id AS "linkedThreadId",
82+
created_at AS "createdAt",
83+
updated_at AS "updatedAt"
84+
FROM decision_cases
85+
WHERE cwd = ${cwd}
86+
ORDER BY updated_at DESC
87+
`,
88+
});
89+
90+
const upsert: DecisionCaseRepositoryShape["upsert"] = (row) =>
91+
upsertRow(row).pipe(
92+
Effect.mapError(
93+
toPersistenceSqlOrDecodeError(
94+
"DecisionCaseRepository.upsert:query",
95+
"DecisionCaseRepository.upsert:encodeRequest",
96+
),
97+
),
98+
);
99+
100+
const getById: DecisionCaseRepositoryShape["getById"] = (input) =>
101+
getRow(input).pipe(
102+
Effect.mapError(
103+
toPersistenceSqlOrDecodeError(
104+
"DecisionCaseRepository.getById:query",
105+
"DecisionCaseRepository.getById:decodeRow",
106+
),
107+
),
108+
Effect.flatMap((rowOption) =>
109+
Option.match(rowOption, {
110+
onNone: () => Effect.succeed(Option.none()),
111+
onSome: (row) =>
112+
Effect.succeed(Option.some(row as Schema.Schema.Type<typeof DecisionCaseRow>)),
113+
}),
114+
),
115+
);
116+
117+
const listByCwd: DecisionCaseRepositoryShape["listByCwd"] = (input) =>
118+
listRows(input).pipe(
119+
Effect.mapError(
120+
toPersistenceSqlOrDecodeError(
121+
"DecisionCaseRepository.listByCwd:query",
122+
"DecisionCaseRepository.listByCwd:decodeRows",
123+
),
124+
),
125+
Effect.map((rows) => rows as ReadonlyArray<Schema.Schema.Type<typeof DecisionCaseRow>>),
126+
);
127+
128+
return { upsert, getById, listByCwd } satisfies DecisionCaseRepositoryShape;
129+
});
130+
131+
export const DecisionCaseRepositoryLive = Layer.effect(
132+
DecisionCaseRepository,
133+
makeDecisionCaseRepository,
134+
);

0 commit comments

Comments
 (0)