@gajae-code/coding-agent 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.md +1 -1
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/commands/launch.d.ts +6 -0
- package/dist/types/config/model-profile-activation.d.ts +30 -0
- package/dist/types/config/model-profiles.d.ts +19 -0
- package/dist/types/config/model-registry.d.ts +25 -10
- package/dist/types/config/model-resolver.d.ts +1 -1
- package/dist/types/config/models-config-schema.d.ts +84 -0
- package/dist/types/config/settings-schema.d.ts +15 -0
- package/dist/types/edit/diff.d.ts +16 -0
- package/dist/types/edit/modes/replace.d.ts +7 -0
- package/dist/types/extensibility/gjc-plugins/activation.d.ts +14 -0
- package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
- package/dist/types/extensibility/gjc-plugins/injection.d.ts +31 -0
- package/dist/types/extensibility/gjc-plugins/loader.d.ts +3 -0
- package/dist/types/extensibility/gjc-plugins/paths.d.ts +8 -0
- package/dist/types/extensibility/gjc-plugins/schema.d.ts +3 -0
- package/dist/types/extensibility/gjc-plugins/state.d.ts +9 -0
- package/dist/types/extensibility/gjc-plugins/tools.d.ts +8 -0
- package/dist/types/extensibility/gjc-plugins/types.d.ts +64 -0
- package/dist/types/extensibility/gjc-plugins/validation.d.ts +4 -0
- package/dist/types/extensibility/skills.d.ts +9 -1
- package/dist/types/gjc-runtime/state-runtime.d.ts +22 -0
- package/dist/types/harness-control-plane/storage.d.ts +7 -0
- package/dist/types/lsp/client.d.ts +1 -0
- package/dist/types/main.d.ts +10 -1
- package/dist/types/modes/bridge/bridge-mode.d.ts +2 -0
- package/dist/types/modes/components/custom-provider-wizard.d.ts +10 -0
- package/dist/types/modes/components/model-selector.d.ts +6 -1
- package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/prompt-action-autocomplete.d.ts +2 -2
- package/dist/types/modes/rpc/rpc-client.d.ts +9 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +179 -2
- package/dist/types/modes/shared/agent-wire/approval-gate.d.ts +57 -0
- package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +16 -1
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +47 -0
- package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +7 -0
- package/dist/types/modes/shared/agent-wire/handshake.d.ts +11 -1
- package/dist/types/modes/shared/agent-wire/protocol.d.ts +3 -1
- package/dist/types/modes/shared/agent-wire/responses.d.ts +1 -1
- package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +27 -0
- package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +68 -0
- package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +161 -0
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +61 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +114 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-schema.d.ts +39 -0
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
- package/dist/types/sdk.d.ts +8 -1
- package/dist/types/session/agent-session.d.ts +10 -0
- package/dist/types/session/blob-store.d.ts +17 -0
- package/dist/types/session/messages.d.ts +3 -0
- package/dist/types/session/session-storage.d.ts +6 -0
- package/dist/types/skill-state/active-state.d.ts +13 -0
- package/dist/types/task/executor.d.ts +1 -0
- package/dist/types/thinking.d.ts +3 -2
- package/dist/types/tools/hindsight-recall.d.ts +0 -2
- package/dist/types/tools/hindsight-reflect.d.ts +0 -2
- package/dist/types/tools/hindsight-retain.d.ts +0 -2
- package/dist/types/tools/index.d.ts +7 -4
- package/package.json +9 -7
- package/src/cli/args.ts +10 -0
- package/src/cli.ts +14 -0
- package/src/commands/harness.ts +192 -7
- package/src/commands/launch.ts +8 -0
- package/src/commands/ultragoal.ts +1 -21
- package/src/config/model-equivalence.ts +1 -1
- package/src/config/model-profile-activation.ts +157 -0
- package/src/config/model-profiles.ts +155 -0
- package/src/config/model-registry.ts +51 -5
- package/src/config/model-resolver.ts +3 -2
- package/src/config/models-config-schema.ts +42 -1
- package/src/config/settings-schema.ts +14 -1
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +11 -1
- package/src/defaults/gjc/skills/ultragoal/ai-slop-cleaner.md +61 -0
- package/src/defaults/gjc-defaults.ts +7 -0
- package/src/discovery/claude-plugins.ts +25 -5
- package/src/edit/diff.ts +64 -1
- package/src/edit/modes/replace.ts +60 -2
- package/src/extensibility/gjc-plugins/activation.ts +87 -0
- package/src/extensibility/gjc-plugins/index.ts +9 -0
- package/src/extensibility/gjc-plugins/injection.ts +114 -0
- package/src/extensibility/gjc-plugins/loader.ts +131 -0
- package/src/extensibility/gjc-plugins/paths.ts +66 -0
- package/src/extensibility/gjc-plugins/schema.ts +79 -0
- package/src/extensibility/gjc-plugins/state.ts +29 -0
- package/src/extensibility/gjc-plugins/tools.ts +47 -0
- package/src/extensibility/gjc-plugins/types.ts +97 -0
- package/src/extensibility/gjc-plugins/validation.ts +76 -0
- package/src/extensibility/skills.ts +39 -7
- package/src/gjc-runtime/state-runtime.ts +93 -2
- package/src/gjc-runtime/state-writer.ts +17 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +62 -2
- package/src/gjc-runtime/workflow-manifest.generated.json +5 -0
- package/src/gjc-runtime/workflow-manifest.ts +2 -2
- package/src/harness-control-plane/storage.ts +144 -2
- package/src/hashline/hash.ts +23 -0
- package/src/hooks/skill-state.ts +2 -0
- package/src/internal-urls/docs-index.generated.ts +8 -11
- package/src/lsp/client.ts +7 -0
- package/src/main.ts +67 -1
- package/src/modes/acp/acp-agent.ts +25 -2
- package/src/modes/bridge/bridge-mode.ts +124 -2
- package/src/modes/components/custom-provider-wizard.ts +318 -0
- package/src/modes/components/model-selector.ts +108 -18
- package/src/modes/components/provider-onboarding-selector.ts +6 -1
- package/src/modes/controllers/input-controller.ts +14 -2
- package/src/modes/controllers/selector-controller.ts +57 -1
- package/src/modes/prompt-action-autocomplete.ts +49 -10
- package/src/modes/rpc/rpc-client.ts +57 -3
- package/src/modes/rpc/rpc-mode.ts +67 -0
- package/src/modes/rpc/rpc-types.ts +224 -2
- package/src/modes/shared/agent-wire/approval-gate.ts +151 -0
- package/src/modes/shared/agent-wire/command-dispatch.ts +97 -4
- package/src/modes/shared/agent-wire/command-validation.ts +25 -1
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +222 -0
- package/src/modes/shared/agent-wire/event-envelope.ts +13 -0
- package/src/modes/shared/agent-wire/handshake.ts +43 -3
- package/src/modes/shared/agent-wire/protocol.ts +7 -0
- package/src/modes/shared/agent-wire/responses.ts +2 -2
- package/src/modes/shared/agent-wire/scopes.ts +2 -0
- package/src/modes/shared/agent-wire/unattended-action-policy.ts +341 -0
- package/src/modes/shared/agent-wire/unattended-audit.ts +175 -0
- package/src/modes/shared/agent-wire/unattended-run-controller.ts +406 -0
- package/src/modes/shared/agent-wire/unattended-session.ts +180 -0
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +324 -0
- package/src/modes/shared/agent-wire/workflow-gate-schema.ts +331 -0
- package/src/modes/theme/theme.ts +6 -0
- package/src/modes/types.ts +1 -0
- package/src/prompts/memories/consolidation.md +1 -1
- package/src/prompts/memories/read-path.md +6 -7
- package/src/prompts/memories/unavailable.md +2 -2
- package/src/prompts/tools/bash.md +1 -1
- package/src/prompts/tools/irc.md +1 -1
- package/src/prompts/tools/read.md +2 -2
- package/src/prompts/tools/recall.md +1 -0
- package/src/prompts/tools/reflect.md +1 -0
- package/src/prompts/tools/retain.md +1 -0
- package/src/runtime-mcp/client.ts +7 -4
- package/src/runtime-mcp/manager.ts +45 -13
- package/src/runtime-mcp/transports/http.ts +40 -14
- package/src/runtime-mcp/transports/stdio.ts +11 -10
- package/src/sdk.ts +48 -1
- package/src/session/agent-session.ts +211 -2
- package/src/session/blob-store.ts +84 -0
- package/src/session/messages.ts +3 -0
- package/src/session/session-manager.ts +390 -33
- package/src/session/session-storage.ts +26 -0
- package/src/setup/provider-onboarding.ts +2 -2
- package/src/skill-state/active-state.ts +89 -1
- package/src/slash-commands/builtin-registry.ts +1 -1
- package/src/task/discovery.ts +7 -1
- package/src/task/executor.ts +18 -2
- package/src/task/index.ts +2 -0
- package/src/thinking.ts +8 -2
- package/src/tools/ask.ts +39 -9
- package/src/tools/hindsight-recall.ts +0 -2
- package/src/tools/hindsight-reflect.ts +0 -2
- package/src/tools/hindsight-retain.ts +0 -2
- package/src/tools/index.ts +7 -18
- package/src/tools/read.ts +3 -3
- package/src/tools/skill.ts +15 -3
- package/src/utils/edit-mode.ts +1 -1
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable workflow gate broker (#315).
|
|
3
|
+
*
|
|
4
|
+
* Owns gate identity and lifecycle for one run:
|
|
5
|
+
* - run-scoped, monotonic, stable gate ids
|
|
6
|
+
* - pending gate persisted BEFORE it is emitted
|
|
7
|
+
* - resolution persisted BEFORE the workflow is allowed to advance
|
|
8
|
+
* - response-body hash + idempotency-key rules (cached replay, conflict detection)
|
|
9
|
+
* - exactly-once advance + audit
|
|
10
|
+
*
|
|
11
|
+
* Persistence is injected via {@link GateStore}; the in-memory store is used in
|
|
12
|
+
* tests, the file-backed store gives crash-durable behavior for real runs.
|
|
13
|
+
*/
|
|
14
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import type {
|
|
17
|
+
RpcWorkflowGate,
|
|
18
|
+
RpcWorkflowGateContext,
|
|
19
|
+
RpcWorkflowGateKind,
|
|
20
|
+
RpcWorkflowGateOption,
|
|
21
|
+
RpcWorkflowGateResolution,
|
|
22
|
+
RpcWorkflowGateResponse,
|
|
23
|
+
RpcWorkflowStage,
|
|
24
|
+
} from "../../rpc/rpc-types";
|
|
25
|
+
import { RESERVED_WORKFLOW_STAGES } from "../../rpc/rpc-types";
|
|
26
|
+
import { answerHashOf, canonicalJson, compileGateSchema, schemaHash, validateGateAnswer } from "./workflow-gate-schema";
|
|
27
|
+
|
|
28
|
+
const V1_STAGES: readonly RpcWorkflowStage[] = ["deep-interview", "ralplan", "ultragoal"];
|
|
29
|
+
|
|
30
|
+
export interface PersistedGate {
|
|
31
|
+
gate: RpcWorkflowGate;
|
|
32
|
+
status: "pending" | "accepted";
|
|
33
|
+
idempotencyKey?: string;
|
|
34
|
+
responseHash?: string;
|
|
35
|
+
/** Raw accepted answer, retained so a crashed advance can be replayed. */
|
|
36
|
+
answer?: unknown;
|
|
37
|
+
resolution?: RpcWorkflowGateResolution;
|
|
38
|
+
advanced: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GateStore {
|
|
42
|
+
nextSeq(stage: RpcWorkflowStage): number;
|
|
43
|
+
put(record: PersistedGate): void;
|
|
44
|
+
get(gateId: string): PersistedGate | undefined;
|
|
45
|
+
/** All persisted gate records (used for crash recovery). */
|
|
46
|
+
list(): PersistedGate[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class MemoryGateStore implements GateStore {
|
|
50
|
+
private counters = new Map<RpcWorkflowStage, number>();
|
|
51
|
+
private gates = new Map<string, PersistedGate>();
|
|
52
|
+
|
|
53
|
+
nextSeq(stage: RpcWorkflowStage): number {
|
|
54
|
+
const next = (this.counters.get(stage) ?? 0) + 1;
|
|
55
|
+
this.counters.set(stage, next);
|
|
56
|
+
return next;
|
|
57
|
+
}
|
|
58
|
+
put(record: PersistedGate): void {
|
|
59
|
+
this.gates.set(record.gate.gate_id, structuredClone(record));
|
|
60
|
+
}
|
|
61
|
+
get(gateId: string): PersistedGate | undefined {
|
|
62
|
+
const r = this.gates.get(gateId);
|
|
63
|
+
return r ? structuredClone(r) : undefined;
|
|
64
|
+
}
|
|
65
|
+
list(): PersistedGate[] {
|
|
66
|
+
return [...this.gates.values()].map(r => structuredClone(r));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface FileState {
|
|
71
|
+
counters: Record<string, number>;
|
|
72
|
+
gates: Record<string, PersistedGate>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Crash-durable JSON-file backed store. Writes the full state on every mutation. */
|
|
76
|
+
export class FileGateStore implements GateStore {
|
|
77
|
+
private state: FileState;
|
|
78
|
+
constructor(private readonly filePath: string) {
|
|
79
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
80
|
+
this.state = this.load();
|
|
81
|
+
}
|
|
82
|
+
private load(): FileState {
|
|
83
|
+
let raw: string;
|
|
84
|
+
try {
|
|
85
|
+
raw = readFileSync(this.filePath, "utf8");
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return { counters: {}, gates: {} };
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(raw) as FileState;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
// Fail closed: a corrupt state file must not be silently reset (that would
|
|
94
|
+
// drop counters/gates and risk gate-id reuse). Quarantine + throw.
|
|
95
|
+
const quarantine = `${this.filePath}.corrupt-${Date.now()}`;
|
|
96
|
+
try {
|
|
97
|
+
renameSync(this.filePath, quarantine);
|
|
98
|
+
} catch {
|
|
99
|
+
/* best-effort quarantine */
|
|
100
|
+
}
|
|
101
|
+
throw new Error(
|
|
102
|
+
`corrupt gate store at ${this.filePath} (quarantined to ${quarantine}): ${(err as Error).message}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
private flush(): void {
|
|
107
|
+
// Atomic write: serialize to a temp file, fsync, then rename over the target.
|
|
108
|
+
const tmp = `${this.filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
109
|
+
const fd = openSync(tmp, "w");
|
|
110
|
+
try {
|
|
111
|
+
writeFileSync(fd, JSON.stringify(this.state, null, 2));
|
|
112
|
+
fsyncSync(fd);
|
|
113
|
+
} finally {
|
|
114
|
+
closeSync(fd);
|
|
115
|
+
}
|
|
116
|
+
renameSync(tmp, this.filePath);
|
|
117
|
+
}
|
|
118
|
+
nextSeq(stage: RpcWorkflowStage): number {
|
|
119
|
+
const next = (this.state.counters[stage] ?? 0) + 1;
|
|
120
|
+
this.state.counters[stage] = next;
|
|
121
|
+
this.flush();
|
|
122
|
+
return next;
|
|
123
|
+
}
|
|
124
|
+
put(record: PersistedGate): void {
|
|
125
|
+
this.state.gates[record.gate.gate_id] = record;
|
|
126
|
+
this.flush();
|
|
127
|
+
}
|
|
128
|
+
get(gateId: string): PersistedGate | undefined {
|
|
129
|
+
const r = this.state.gates[gateId];
|
|
130
|
+
return r ? (JSON.parse(JSON.stringify(r)) as PersistedGate) : undefined;
|
|
131
|
+
}
|
|
132
|
+
list(): PersistedGate[] {
|
|
133
|
+
return Object.values(this.state.gates).map(r => JSON.parse(JSON.stringify(r)) as PersistedGate);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type GateAuditEvent =
|
|
138
|
+
| { event: "gate_emitted"; gate_id: string; stage: RpcWorkflowStage; kind: RpcWorkflowGateKind }
|
|
139
|
+
| { event: "gate_response_accepted"; gate_id: string; answer_hash: string }
|
|
140
|
+
| { event: "gate_response_rejected"; gate_id: string; answer_hash: string }
|
|
141
|
+
| { event: "gate_response_idempotent_replay"; gate_id: string }
|
|
142
|
+
| { event: "gate_response_idempotency_conflict"; gate_id: string }
|
|
143
|
+
| { event: "gate_response_already_resolved"; gate_id: string }
|
|
144
|
+
| { event: "gate_response_unknown_gate"; gate_id: string }
|
|
145
|
+
| { event: "gate_advance_recovered"; gate_id: string };
|
|
146
|
+
|
|
147
|
+
export interface BrokerHooks {
|
|
148
|
+
/** Called once when a pending gate has been persisted and should be emitted. */
|
|
149
|
+
emit?(gate: RpcWorkflowGate): void;
|
|
150
|
+
/**
|
|
151
|
+
* Invoked to advance the workflow after an accepted resolution is durably
|
|
152
|
+
* committed. MUST be idempotent keyed by `gate.gate_id`: `recover()` replays
|
|
153
|
+
* it for any gate left `accepted` but not `advanced` by a crash.
|
|
154
|
+
*/
|
|
155
|
+
advance?(gate: RpcWorkflowGate, answer: unknown): void | Promise<void>;
|
|
156
|
+
/** Append-only audit sink. */
|
|
157
|
+
audit?(event: GateAuditEvent): void;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface OpenGateInput {
|
|
161
|
+
stage: RpcWorkflowStage;
|
|
162
|
+
kind: RpcWorkflowGateKind;
|
|
163
|
+
schema: RpcWorkflowGate["schema"];
|
|
164
|
+
options?: RpcWorkflowGateOption[];
|
|
165
|
+
context?: RpcWorkflowGateContext;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export class WorkflowGateBrokerError extends Error {
|
|
169
|
+
constructor(
|
|
170
|
+
readonly code: "unknown_gate" | "already_resolved" | "idempotency_conflict" | "invalid_workflow_stage",
|
|
171
|
+
message: string,
|
|
172
|
+
) {
|
|
173
|
+
super(message);
|
|
174
|
+
this.name = "WorkflowGateBrokerError";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export class WorkflowGateBroker {
|
|
179
|
+
constructor(
|
|
180
|
+
private readonly runId: string,
|
|
181
|
+
private readonly store: GateStore,
|
|
182
|
+
private readonly hooks: BrokerHooks = {},
|
|
183
|
+
) {}
|
|
184
|
+
|
|
185
|
+
private runShort(): string {
|
|
186
|
+
return this.runId.replace(/[^a-zA-Z0-9]/g, "").slice(-8) || "run";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Open and emit a gate. The pending record is persisted BEFORE emission. */
|
|
190
|
+
openGate(input: OpenGateInput): RpcWorkflowGate {
|
|
191
|
+
if (RESERVED_WORKFLOW_STAGES.includes(input.stage) || !V1_STAGES.includes(input.stage)) {
|
|
192
|
+
throw new WorkflowGateBrokerError(
|
|
193
|
+
"invalid_workflow_stage",
|
|
194
|
+
`stage "${input.stage}" is not a v1 workflow stage`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
// Asserts schema shape (throws WorkflowGateSchemaError on unsupported keywords).
|
|
198
|
+
compileGateSchema(input.schema);
|
|
199
|
+
const seq = this.store.nextSeq(input.stage).toString().padStart(6, "0");
|
|
200
|
+
const gateId = `wg_${this.runShort()}_${input.stage}_${seq}`;
|
|
201
|
+
const gate: RpcWorkflowGate = {
|
|
202
|
+
type: "workflow_gate",
|
|
203
|
+
gate_id: gateId,
|
|
204
|
+
stage: input.stage,
|
|
205
|
+
kind: input.kind,
|
|
206
|
+
schema: input.schema,
|
|
207
|
+
schema_hash: schemaHash(input.schema),
|
|
208
|
+
options: input.options,
|
|
209
|
+
context: input.context ?? {},
|
|
210
|
+
created_at: new Date().toISOString(),
|
|
211
|
+
required: true,
|
|
212
|
+
};
|
|
213
|
+
// Persist pending BEFORE emit so a crash never loses an emitted gate.
|
|
214
|
+
this.store.put({ gate, status: "pending", advanced: false });
|
|
215
|
+
this.hooks.emit?.(gate);
|
|
216
|
+
this.hooks.audit?.({ event: "gate_emitted", gate_id: gateId, stage: gate.stage, kind: gate.kind });
|
|
217
|
+
return gate;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Resolve a gate with an answer. Validates against the advertised schema.
|
|
222
|
+
* On success the resolution is persisted BEFORE `advance` is invoked exactly
|
|
223
|
+
* once. Invalid answers leave the gate pending (per #315 acceptance).
|
|
224
|
+
*/
|
|
225
|
+
async resolve(response: RpcWorkflowGateResponse): Promise<RpcWorkflowGateResolution> {
|
|
226
|
+
const record = this.store.get(response.gate_id);
|
|
227
|
+
if (!record) {
|
|
228
|
+
this.hooks.audit?.({ event: "gate_response_unknown_gate", gate_id: response.gate_id });
|
|
229
|
+
throw new WorkflowGateBrokerError("unknown_gate", `no pending gate ${response.gate_id}`);
|
|
230
|
+
}
|
|
231
|
+
const responseHash = answerHashOf({ gate_id: response.gate_id, answer: response.answer });
|
|
232
|
+
|
|
233
|
+
if (record.status === "accepted") {
|
|
234
|
+
const sameBody = record.responseHash === responseHash;
|
|
235
|
+
const sameKey = record.idempotencyKey === response.idempotency_key;
|
|
236
|
+
if (response.idempotency_key !== undefined && sameKey && sameBody) {
|
|
237
|
+
this.hooks.audit?.({ event: "gate_response_idempotent_replay", gate_id: response.gate_id });
|
|
238
|
+
return record.resolution as RpcWorkflowGateResolution;
|
|
239
|
+
}
|
|
240
|
+
if (response.idempotency_key !== undefined && sameKey && !sameBody) {
|
|
241
|
+
this.hooks.audit?.({ event: "gate_response_idempotency_conflict", gate_id: response.gate_id });
|
|
242
|
+
throw new WorkflowGateBrokerError(
|
|
243
|
+
"idempotency_conflict",
|
|
244
|
+
`idempotency_conflict: gate ${response.gate_id} resolved with a different body`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
this.hooks.audit?.({ event: "gate_response_already_resolved", gate_id: response.gate_id });
|
|
248
|
+
throw new WorkflowGateBrokerError("already_resolved", `already_resolved: gate ${response.gate_id}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const compiled = compileGateSchema(record.gate.schema);
|
|
252
|
+
const validationError = validateGateAnswer(compiled, response.gate_id, response.answer);
|
|
253
|
+
const answerHash = answerHashOf(response.answer);
|
|
254
|
+
if (validationError) {
|
|
255
|
+
// Leave the gate pending so the agent can retry with a valid answer.
|
|
256
|
+
this.hooks.audit?.({ event: "gate_response_rejected", gate_id: response.gate_id, answer_hash: answerHash });
|
|
257
|
+
return {
|
|
258
|
+
gate_id: response.gate_id,
|
|
259
|
+
status: "rejected",
|
|
260
|
+
answer_hash: answerHash,
|
|
261
|
+
resolved_at: new Date().toISOString(),
|
|
262
|
+
error: validationError,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const resolution: RpcWorkflowGateResolution = {
|
|
267
|
+
gate_id: response.gate_id,
|
|
268
|
+
status: "accepted",
|
|
269
|
+
answer_hash: answerHash,
|
|
270
|
+
resolved_at: new Date().toISOString(),
|
|
271
|
+
};
|
|
272
|
+
// Persist resolution BEFORE advancing the workflow (exactly-once advance).
|
|
273
|
+
// `answer` is retained so a crash before the advanced:true write can be
|
|
274
|
+
// recovered via recover().
|
|
275
|
+
this.store.put({
|
|
276
|
+
gate: record.gate,
|
|
277
|
+
status: "accepted",
|
|
278
|
+
idempotencyKey: response.idempotency_key,
|
|
279
|
+
responseHash,
|
|
280
|
+
answer: response.answer,
|
|
281
|
+
resolution,
|
|
282
|
+
advanced: false,
|
|
283
|
+
});
|
|
284
|
+
this.hooks.audit?.({ event: "gate_response_accepted", gate_id: response.gate_id, answer_hash: answerHash });
|
|
285
|
+
await this.hooks.advance?.(record.gate, response.answer);
|
|
286
|
+
this.store.put({
|
|
287
|
+
gate: record.gate,
|
|
288
|
+
status: "accepted",
|
|
289
|
+
idempotencyKey: response.idempotency_key,
|
|
290
|
+
responseHash,
|
|
291
|
+
answer: response.answer,
|
|
292
|
+
resolution,
|
|
293
|
+
advanced: true,
|
|
294
|
+
});
|
|
295
|
+
return resolution;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Recover any gate left `accepted` but not `advanced` by a crash between the
|
|
300
|
+
* durable accept and the advanced commit. Replays `advance` (which must be
|
|
301
|
+
* idempotent) exactly once per gate and marks it advanced. Returns the ids
|
|
302
|
+
* that were recovered.
|
|
303
|
+
*/
|
|
304
|
+
async recover(): Promise<string[]> {
|
|
305
|
+
const recovered: string[] = [];
|
|
306
|
+
for (const listed of this.store.list()) {
|
|
307
|
+
if (listed.status !== "accepted" || listed.advanced) continue;
|
|
308
|
+
// Re-read immediately before advancing so a concurrent recoverer that
|
|
309
|
+
// already advanced this gate (stale snapshot) cannot double-advance.
|
|
310
|
+
const rec = this.store.get(listed.gate.gate_id);
|
|
311
|
+
if (rec?.status !== "accepted" || rec.advanced) continue;
|
|
312
|
+
await this.hooks.advance?.(rec.gate, rec.answer);
|
|
313
|
+
this.store.put({ ...rec, advanced: true });
|
|
314
|
+
this.hooks.audit?.({ event: "gate_advance_recovered", gate_id: rec.gate.gate_id });
|
|
315
|
+
recovered.push(rec.gate.gate_id);
|
|
316
|
+
}
|
|
317
|
+
return recovered;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Canonical serialization helper (exposed for callers/tests). */
|
|
321
|
+
static canonical(value: unknown): string {
|
|
322
|
+
return canonicalJson(value);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow gate answer-schema wrapper (#315).
|
|
3
|
+
*
|
|
4
|
+
* Validates gate answers against the JSON Schema advertised with each
|
|
5
|
+
* `workflow_gate` event. The dialect is a documented, constrained subset of
|
|
6
|
+
* JSON Schema 2020-12. Schemas are checked for shape (supported keywords, size,
|
|
7
|
+
* depth) at gate-construction time so the server never advertises a schema it
|
|
8
|
+
* cannot validate; compiled validators are cached by canonical schema hash.
|
|
9
|
+
*
|
|
10
|
+
* The validation internals are intentionally isolated behind {@link compileGateSchema}
|
|
11
|
+
* and {@link validateGateAnswer} so a full JSON Schema engine (e.g. Ajv) can be
|
|
12
|
+
* swapped in later without changing callers.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import type { RpcJsonSchema, RpcWorkflowGateValidationError } from "../../rpc/rpc-types";
|
|
16
|
+
|
|
17
|
+
/** Keywords this wrapper understands. Any other keyword is rejected. */
|
|
18
|
+
const SUPPORTED_KEYWORDS = new Set<keyof RpcJsonSchema>([
|
|
19
|
+
"type",
|
|
20
|
+
"enum",
|
|
21
|
+
"const",
|
|
22
|
+
"properties",
|
|
23
|
+
"required",
|
|
24
|
+
"additionalProperties",
|
|
25
|
+
"items",
|
|
26
|
+
"minLength",
|
|
27
|
+
"maxLength",
|
|
28
|
+
"minItems",
|
|
29
|
+
"maxItems",
|
|
30
|
+
"uniqueItems",
|
|
31
|
+
"minimum",
|
|
32
|
+
"maximum",
|
|
33
|
+
"title",
|
|
34
|
+
"description",
|
|
35
|
+
"oneOf",
|
|
36
|
+
"anyOf",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const SUPPORTED_TYPES = new Set(["string", "number", "integer", "boolean", "object", "array", "null"]);
|
|
40
|
+
|
|
41
|
+
export const GATE_SCHEMA_LIMITS = {
|
|
42
|
+
maxSchemaBytes: 64 * 1024,
|
|
43
|
+
maxDepth: 16,
|
|
44
|
+
maxProperties: 256,
|
|
45
|
+
maxEnumValues: 512,
|
|
46
|
+
maxAnswerBytes: 256 * 1024,
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
/** Thrown at gate construction when a schema is unsupported or too large. */
|
|
50
|
+
export class WorkflowGateSchemaError extends Error {
|
|
51
|
+
readonly code = "invalid_workflow_gate_schema";
|
|
52
|
+
constructor(message: string) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = "WorkflowGateSchemaError";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Canonical (stable-key-ordered) JSON serialization used for hashing. */
|
|
59
|
+
export function canonicalJson(value: unknown): string {
|
|
60
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
61
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
62
|
+
const keys = Object.keys(value as Record<string, unknown>).sort();
|
|
63
|
+
const entries = keys.map(k => `${JSON.stringify(k)}:${canonicalJson((value as Record<string, unknown>)[k])}`);
|
|
64
|
+
return `{${entries.join(",")}}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function schemaHash(schema: RpcJsonSchema): string {
|
|
68
|
+
return createHash("sha256").update(canonicalJson(schema)).digest("hex");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function answerHashOf(answer: unknown): string {
|
|
72
|
+
return createHash("sha256").update(canonicalJson(answer)).digest("hex");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Validate the schema *shape*. Throws WorkflowGateSchemaError on any problem. */
|
|
76
|
+
export function assertSupportedGateSchema(schema: RpcJsonSchema): void {
|
|
77
|
+
const serialized = canonicalJson(schema);
|
|
78
|
+
if (Buffer.byteLength(serialized, "utf8") > GATE_SCHEMA_LIMITS.maxSchemaBytes) {
|
|
79
|
+
throw new WorkflowGateSchemaError(`schema exceeds ${GATE_SCHEMA_LIMITS.maxSchemaBytes} bytes`);
|
|
80
|
+
}
|
|
81
|
+
walkSchema(schema, 0, "#");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function walkSchema(schema: RpcJsonSchema, depth: number, path: string): void {
|
|
85
|
+
if (depth > GATE_SCHEMA_LIMITS.maxDepth) {
|
|
86
|
+
throw new WorkflowGateSchemaError(`schema nesting exceeds depth ${GATE_SCHEMA_LIMITS.maxDepth} at ${path}`);
|
|
87
|
+
}
|
|
88
|
+
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
|
|
89
|
+
throw new WorkflowGateSchemaError(`schema node at ${path} must be an object`);
|
|
90
|
+
}
|
|
91
|
+
for (const key of Object.keys(schema)) {
|
|
92
|
+
if (!SUPPORTED_KEYWORDS.has(key as keyof RpcJsonSchema)) {
|
|
93
|
+
throw new WorkflowGateSchemaError(`unsupported keyword "${key}" at ${path}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (schema.type !== undefined && !SUPPORTED_TYPES.has(schema.type)) {
|
|
97
|
+
throw new WorkflowGateSchemaError(`unsupported type "${schema.type}" at ${path}`);
|
|
98
|
+
}
|
|
99
|
+
if (schema.enum !== undefined) {
|
|
100
|
+
if (!Array.isArray(schema.enum)) throw new WorkflowGateSchemaError(`enum at ${path} must be an array`);
|
|
101
|
+
if (schema.enum.length > GATE_SCHEMA_LIMITS.maxEnumValues) {
|
|
102
|
+
throw new WorkflowGateSchemaError(`enum at ${path} exceeds ${GATE_SCHEMA_LIMITS.maxEnumValues} values`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const meta of ["title", "description"] as const) {
|
|
106
|
+
if (schema[meta] !== undefined && typeof schema[meta] !== "string") {
|
|
107
|
+
throw new WorkflowGateSchemaError(`${meta} at ${path} must be a string`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const limit of ["minLength", "maxLength", "minItems", "maxItems"] as const) {
|
|
111
|
+
const v = schema[limit];
|
|
112
|
+
if (v !== undefined && (typeof v !== "number" || !Number.isInteger(v) || v < 0)) {
|
|
113
|
+
throw new WorkflowGateSchemaError(`${limit} at ${path} must be a non-negative integer`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (schema.uniqueItems !== undefined && typeof schema.uniqueItems !== "boolean") {
|
|
117
|
+
throw new WorkflowGateSchemaError(`uniqueItems at ${path} must be a boolean`);
|
|
118
|
+
}
|
|
119
|
+
for (const limit of ["minimum", "maximum"] as const) {
|
|
120
|
+
const v = schema[limit];
|
|
121
|
+
if (v !== undefined && (typeof v !== "number" || !Number.isFinite(v))) {
|
|
122
|
+
throw new WorkflowGateSchemaError(`${limit} at ${path} must be a finite number`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (
|
|
126
|
+
schema.required !== undefined &&
|
|
127
|
+
!(Array.isArray(schema.required) && schema.required.every(r => typeof r === "string"))
|
|
128
|
+
) {
|
|
129
|
+
throw new WorkflowGateSchemaError(`required at ${path} must be an array of strings`);
|
|
130
|
+
}
|
|
131
|
+
if (schema.properties !== undefined) {
|
|
132
|
+
if (typeof schema.properties !== "object" || schema.properties === null || Array.isArray(schema.properties)) {
|
|
133
|
+
throw new WorkflowGateSchemaError(`properties at ${path} must be an object`);
|
|
134
|
+
}
|
|
135
|
+
const propKeys = Object.keys(schema.properties);
|
|
136
|
+
if (propKeys.length > GATE_SCHEMA_LIMITS.maxProperties) {
|
|
137
|
+
throw new WorkflowGateSchemaError(`properties at ${path} exceed ${GATE_SCHEMA_LIMITS.maxProperties}`);
|
|
138
|
+
}
|
|
139
|
+
for (const k of propKeys) walkSchema(schema.properties[k] as RpcJsonSchema, depth + 1, `${path}/properties/${k}`);
|
|
140
|
+
}
|
|
141
|
+
if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== "boolean") {
|
|
142
|
+
if (
|
|
143
|
+
typeof schema.additionalProperties !== "object" ||
|
|
144
|
+
schema.additionalProperties === null ||
|
|
145
|
+
Array.isArray(schema.additionalProperties)
|
|
146
|
+
) {
|
|
147
|
+
throw new WorkflowGateSchemaError(`additionalProperties at ${path} must be a boolean or schema object`);
|
|
148
|
+
}
|
|
149
|
+
walkSchema(schema.additionalProperties, depth + 1, `${path}/additionalProperties`);
|
|
150
|
+
}
|
|
151
|
+
if (schema.items !== undefined) walkSchema(schema.items, depth + 1, `${path}/items`);
|
|
152
|
+
for (const combiner of ["oneOf", "anyOf"] as const) {
|
|
153
|
+
const branches = schema[combiner];
|
|
154
|
+
if (branches !== undefined) {
|
|
155
|
+
if (!Array.isArray(branches)) throw new WorkflowGateSchemaError(`${combiner} at ${path} must be an array`);
|
|
156
|
+
for (let i = 0; i < branches.length; i++)
|
|
157
|
+
walkSchema(branches[i] as RpcJsonSchema, depth + 1, `${path}/${combiner}/${i}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
type SchemaError = { path: string; keyword: string; message: string; expected?: unknown };
|
|
163
|
+
|
|
164
|
+
/** A compiled, cached validator for one schema. */
|
|
165
|
+
export interface CompiledGateSchema {
|
|
166
|
+
readonly schema: RpcJsonSchema;
|
|
167
|
+
readonly hash: string;
|
|
168
|
+
validate(answer: unknown): SchemaError[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const compileCache = new Map<string, CompiledGateSchema>();
|
|
172
|
+
|
|
173
|
+
/** Compile (and cache) a validator for a schema. Asserts shape on first compile. */
|
|
174
|
+
export function compileGateSchema(schema: RpcJsonSchema): CompiledGateSchema {
|
|
175
|
+
const hash = schemaHash(schema);
|
|
176
|
+
const cached = compileCache.get(hash);
|
|
177
|
+
if (cached) return cached;
|
|
178
|
+
assertSupportedGateSchema(schema);
|
|
179
|
+
const compiled: CompiledGateSchema = {
|
|
180
|
+
schema,
|
|
181
|
+
hash,
|
|
182
|
+
validate: answer => {
|
|
183
|
+
const errors: SchemaError[] = [];
|
|
184
|
+
const serialized = canonicalJson(answer);
|
|
185
|
+
if (Buffer.byteLength(serialized, "utf8") > GATE_SCHEMA_LIMITS.maxAnswerBytes) {
|
|
186
|
+
errors.push({ path: "#", keyword: "maxAnswerBytes", message: "answer too large" });
|
|
187
|
+
return errors;
|
|
188
|
+
}
|
|
189
|
+
validateValue(schema, answer, "#", errors);
|
|
190
|
+
return errors;
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
compileCache.set(hash, compiled);
|
|
194
|
+
return compiled;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function typeMatches(type: NonNullable<RpcJsonSchema["type"]>, value: unknown): boolean {
|
|
198
|
+
switch (type) {
|
|
199
|
+
case "string":
|
|
200
|
+
return typeof value === "string";
|
|
201
|
+
case "number":
|
|
202
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
203
|
+
case "integer":
|
|
204
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
205
|
+
case "boolean":
|
|
206
|
+
return typeof value === "boolean";
|
|
207
|
+
case "object":
|
|
208
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
209
|
+
case "array":
|
|
210
|
+
return Array.isArray(value);
|
|
211
|
+
case "null":
|
|
212
|
+
return value === null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function validateValue(schema: RpcJsonSchema, value: unknown, path: string, errors: SchemaError[]): void {
|
|
217
|
+
if (schema.type !== undefined && !typeMatches(schema.type, value)) {
|
|
218
|
+
errors.push({ path, keyword: "type", message: `expected ${schema.type}`, expected: schema.type });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (schema.const !== undefined && canonicalJson(value) !== canonicalJson(schema.const)) {
|
|
222
|
+
errors.push({ path, keyword: "const", message: "value does not equal const", expected: schema.const });
|
|
223
|
+
}
|
|
224
|
+
if (schema.enum !== undefined) {
|
|
225
|
+
const ok = schema.enum.some(e => canonicalJson(e) === canonicalJson(value));
|
|
226
|
+
if (!ok) errors.push({ path, keyword: "enum", message: "value not in enum", expected: schema.enum });
|
|
227
|
+
}
|
|
228
|
+
if (typeof value === "string") {
|
|
229
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) {
|
|
230
|
+
errors.push({
|
|
231
|
+
path,
|
|
232
|
+
keyword: "minLength",
|
|
233
|
+
message: `shorter than ${schema.minLength}`,
|
|
234
|
+
expected: schema.minLength,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
if (schema.maxLength !== undefined && value.length > schema.maxLength) {
|
|
238
|
+
errors.push({
|
|
239
|
+
path,
|
|
240
|
+
keyword: "maxLength",
|
|
241
|
+
message: `longer than ${schema.maxLength}`,
|
|
242
|
+
expected: schema.maxLength,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (typeof value === "number") {
|
|
247
|
+
if (schema.minimum !== undefined && value < schema.minimum) {
|
|
248
|
+
errors.push({ path, keyword: "minimum", message: `less than ${schema.minimum}`, expected: schema.minimum });
|
|
249
|
+
}
|
|
250
|
+
if (schema.maximum !== undefined && value > schema.maximum) {
|
|
251
|
+
errors.push({ path, keyword: "maximum", message: `greater than ${schema.maximum}`, expected: schema.maximum });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
255
|
+
const obj = value as Record<string, unknown>;
|
|
256
|
+
for (const req of schema.required ?? []) {
|
|
257
|
+
if (!(req in obj))
|
|
258
|
+
errors.push({ path: `${path}/${req}`, keyword: "required", message: "missing required property" });
|
|
259
|
+
}
|
|
260
|
+
const props = schema.properties ?? {};
|
|
261
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
262
|
+
if (props[k]) {
|
|
263
|
+
validateValue(props[k], v, `${path}/${k}`, errors);
|
|
264
|
+
} else if (schema.additionalProperties === false) {
|
|
265
|
+
errors.push({ path: `${path}/${k}`, keyword: "additionalProperties", message: "unexpected property" });
|
|
266
|
+
} else if (typeof schema.additionalProperties === "object" && schema.additionalProperties !== null) {
|
|
267
|
+
validateValue(schema.additionalProperties, v, `${path}/${k}`, errors);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (Array.isArray(value)) {
|
|
272
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) {
|
|
273
|
+
errors.push({
|
|
274
|
+
path,
|
|
275
|
+
keyword: "minItems",
|
|
276
|
+
message: `fewer than ${schema.minItems} items`,
|
|
277
|
+
expected: schema.minItems,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
if (schema.maxItems !== undefined && value.length > schema.maxItems) {
|
|
281
|
+
errors.push({
|
|
282
|
+
path,
|
|
283
|
+
keyword: "maxItems",
|
|
284
|
+
message: `more than ${schema.maxItems} items`,
|
|
285
|
+
expected: schema.maxItems,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
if (schema.uniqueItems) {
|
|
289
|
+
const seen = new Set<string>();
|
|
290
|
+
for (const item of value) {
|
|
291
|
+
const key = canonicalJson(item);
|
|
292
|
+
if (seen.has(key)) {
|
|
293
|
+
errors.push({ path, keyword: "uniqueItems", message: "array items must be unique" });
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
seen.add(key);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (schema.items) {
|
|
300
|
+
for (let i = 0; i < value.length; i++)
|
|
301
|
+
validateValue(schema.items as RpcJsonSchema, value[i], `${path}/${i}`, errors);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
for (const combiner of ["oneOf", "anyOf"] as const) {
|
|
305
|
+
const branches = schema[combiner];
|
|
306
|
+
if (!branches) continue;
|
|
307
|
+
const matchCount = branches.filter(b => {
|
|
308
|
+
const sub: SchemaError[] = [];
|
|
309
|
+
validateValue(b, value, path, sub);
|
|
310
|
+
return sub.length === 0;
|
|
311
|
+
}).length;
|
|
312
|
+
const ok = combiner === "oneOf" ? matchCount === 1 : matchCount >= 1;
|
|
313
|
+
if (!ok) errors.push({ path, keyword: combiner, message: `value did not satisfy ${combiner}` });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Validate an answer against a compiled gate schema. Returns `null` on success
|
|
319
|
+
* or a typed {@link RpcWorkflowGateValidationError} on mismatch.
|
|
320
|
+
*/
|
|
321
|
+
export function validateGateAnswer(
|
|
322
|
+
compiled: CompiledGateSchema,
|
|
323
|
+
gateId: string,
|
|
324
|
+
answer: unknown,
|
|
325
|
+
): RpcWorkflowGateValidationError | null {
|
|
326
|
+
const errors = compiled.validate(answer);
|
|
327
|
+
if (errors.length === 0) return null;
|
|
328
|
+
return { code: "invalid_workflow_gate_answer", gate_id: gateId, schema_hash: compiled.hash, errors };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export { answerHashOf };
|
package/src/modes/theme/theme.ts
CHANGED
|
@@ -129,6 +129,7 @@ export type SymbolKey =
|
|
|
129
129
|
| "thinking.medium"
|
|
130
130
|
| "thinking.high"
|
|
131
131
|
| "thinking.xhigh"
|
|
132
|
+
| "thinking.max"
|
|
132
133
|
// Checkboxes
|
|
133
134
|
| "checkbox.checked"
|
|
134
135
|
| "checkbox.unchecked"
|
|
@@ -292,6 +293,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
|
|
|
292
293
|
"thinking.medium": "◒ med",
|
|
293
294
|
"thinking.high": "◕ high",
|
|
294
295
|
"thinking.xhigh": "◉ xhigh",
|
|
296
|
+
"thinking.max": "◉ max",
|
|
295
297
|
// Checkboxes
|
|
296
298
|
"checkbox.checked": "☑",
|
|
297
299
|
"checkbox.unchecked": "☐",
|
|
@@ -542,6 +544,7 @@ const NERD_SYMBOLS: SymbolMap = {
|
|
|
542
544
|
"thinking.high": "\u{F111} high",
|
|
543
545
|
// pick: 🧠 xhi | alt: xhi xhi
|
|
544
546
|
"thinking.xhigh": "\u{F06D} xhi",
|
|
547
|
+
"thinking.max": "\u{F06D} max",
|
|
545
548
|
// Checkboxes
|
|
546
549
|
// pick: | alt:
|
|
547
550
|
"checkbox.checked": "\uf14a",
|
|
@@ -712,6 +715,7 @@ const ASCII_SYMBOLS: SymbolMap = {
|
|
|
712
715
|
"thinking.medium": "[med]",
|
|
713
716
|
"thinking.high": "[high]",
|
|
714
717
|
"thinking.xhigh": "[xhi]",
|
|
718
|
+
"thinking.max": "[max]",
|
|
715
719
|
// Checkboxes
|
|
716
720
|
"checkbox.checked": "[x]",
|
|
717
721
|
"checkbox.unchecked": "[ ]",
|
|
@@ -1316,6 +1320,7 @@ export class Theme {
|
|
|
1316
1320
|
case "high":
|
|
1317
1321
|
return (str: string) => this.fg("thinkingHigh", str);
|
|
1318
1322
|
case "xhigh":
|
|
1323
|
+
case "max":
|
|
1319
1324
|
return (str: string) => this.fg("thinkingXhigh", str);
|
|
1320
1325
|
default:
|
|
1321
1326
|
return (str: string) => this.fg("thinkingOff", str);
|
|
@@ -1487,6 +1492,7 @@ export class Theme {
|
|
|
1487
1492
|
medium: this.#symbols["thinking.medium"],
|
|
1488
1493
|
high: this.#symbols["thinking.high"],
|
|
1489
1494
|
xhigh: this.#symbols["thinking.xhigh"],
|
|
1495
|
+
max: this.#symbols["thinking.max"],
|
|
1490
1496
|
};
|
|
1491
1497
|
}
|
|
1492
1498
|
|