twinclaw 1.0.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.
Files changed (132) hide show
  1. package/README.md +66 -0
  2. package/bin/npm-twinclaw.js +17 -0
  3. package/bin/run-twinbot-cli.js +36 -0
  4. package/bin/twinbot.js +4 -0
  5. package/bin/twinclaw.js +4 -0
  6. package/dist/api/handlers/browser.js +160 -0
  7. package/dist/api/handlers/callback.js +80 -0
  8. package/dist/api/handlers/config-validate.js +19 -0
  9. package/dist/api/handlers/health.js +117 -0
  10. package/dist/api/handlers/local-state-backup.js +118 -0
  11. package/dist/api/handlers/persona-state.js +59 -0
  12. package/dist/api/handlers/skill-packages.js +94 -0
  13. package/dist/api/router.js +278 -0
  14. package/dist/api/runtime-event-producer.js +99 -0
  15. package/dist/api/shared.js +82 -0
  16. package/dist/api/websocket-hub.js +305 -0
  17. package/dist/config/config-loader.js +2 -0
  18. package/dist/config/env-schema.js +202 -0
  19. package/dist/config/env-validator.js +223 -0
  20. package/dist/config/identity-bootstrap.js +115 -0
  21. package/dist/config/json-config.js +344 -0
  22. package/dist/config/workspace.js +186 -0
  23. package/dist/core/channels-cli.js +77 -0
  24. package/dist/core/cli.js +119 -0
  25. package/dist/core/context-assembly.js +33 -0
  26. package/dist/core/doctor.js +365 -0
  27. package/dist/core/gateway-cli.js +323 -0
  28. package/dist/core/gateway.js +416 -0
  29. package/dist/core/heartbeat.js +54 -0
  30. package/dist/core/install-cli.js +320 -0
  31. package/dist/core/lane-executor.js +134 -0
  32. package/dist/core/logs-cli.js +70 -0
  33. package/dist/core/onboarding.js +760 -0
  34. package/dist/core/pairing-cli.js +78 -0
  35. package/dist/core/secret-vault-cli.js +204 -0
  36. package/dist/core/types.js +1 -0
  37. package/dist/index.js +404 -0
  38. package/dist/interfaces/dispatcher.js +214 -0
  39. package/dist/interfaces/telegram_handler.js +82 -0
  40. package/dist/interfaces/tui-dashboard.js +53 -0
  41. package/dist/interfaces/whatsapp_handler.js +94 -0
  42. package/dist/release/cli.js +97 -0
  43. package/dist/release/mvp-gate-cli.js +118 -0
  44. package/dist/release/twinbot-config-schema.js +162 -0
  45. package/dist/release/twinclaw-config-schema.js +162 -0
  46. package/dist/services/block-chunker.js +174 -0
  47. package/dist/services/browser-service.js +334 -0
  48. package/dist/services/context-lifecycle.js +314 -0
  49. package/dist/services/db.js +1055 -0
  50. package/dist/services/delivery-tracker.js +110 -0
  51. package/dist/services/dm-pairing.js +245 -0
  52. package/dist/services/embedding-service.js +125 -0
  53. package/dist/services/file-watcher.js +125 -0
  54. package/dist/services/inbound-debounce.js +92 -0
  55. package/dist/services/incident-manager.js +516 -0
  56. package/dist/services/job-scheduler.js +176 -0
  57. package/dist/services/local-state-backup.js +682 -0
  58. package/dist/services/mcp-client-adapter.js +291 -0
  59. package/dist/services/mcp-server-manager.js +143 -0
  60. package/dist/services/model-router.js +927 -0
  61. package/dist/services/mvp-gate.js +845 -0
  62. package/dist/services/orchestration-service.js +422 -0
  63. package/dist/services/persona-state.js +256 -0
  64. package/dist/services/policy-engine.js +92 -0
  65. package/dist/services/proactive-notifier.js +94 -0
  66. package/dist/services/queue-service.js +146 -0
  67. package/dist/services/release-pipeline.js +652 -0
  68. package/dist/services/runtime-budget-governor.js +415 -0
  69. package/dist/services/secret-vault.js +704 -0
  70. package/dist/services/semantic-memory.js +249 -0
  71. package/dist/services/skill-package-manager.js +806 -0
  72. package/dist/services/skill-registry.js +122 -0
  73. package/dist/services/streaming-output.js +75 -0
  74. package/dist/services/stt-service.js +39 -0
  75. package/dist/services/tts-service.js +44 -0
  76. package/dist/skills/builtin.js +250 -0
  77. package/dist/skills/shell.js +87 -0
  78. package/dist/skills/types.js +1 -0
  79. package/dist/types/api.js +1 -0
  80. package/dist/types/context-budget.js +1 -0
  81. package/dist/types/doctor.js +1 -0
  82. package/dist/types/file-watcher.js +1 -0
  83. package/dist/types/incident.js +1 -0
  84. package/dist/types/local-state-backup.js +1 -0
  85. package/dist/types/mcp.js +1 -0
  86. package/dist/types/messaging.js +1 -0
  87. package/dist/types/model-routing.js +1 -0
  88. package/dist/types/mvp-gate.js +2 -0
  89. package/dist/types/orchestration.js +1 -0
  90. package/dist/types/persona-state.js +22 -0
  91. package/dist/types/policy.js +1 -0
  92. package/dist/types/reasoning-graph.js +1 -0
  93. package/dist/types/release.js +1 -0
  94. package/dist/types/reliability.js +1 -0
  95. package/dist/types/runtime-budget.js +1 -0
  96. package/dist/types/scheduler.js +1 -0
  97. package/dist/types/secret-vault.js +1 -0
  98. package/dist/types/skill-packages.js +1 -0
  99. package/dist/types/websocket.js +14 -0
  100. package/dist/utils/logger.js +57 -0
  101. package/dist/utils/retry.js +61 -0
  102. package/dist/utils/secret-scan.js +208 -0
  103. package/mcp-servers.json +179 -0
  104. package/package.json +81 -0
  105. package/skill-packages.json +92 -0
  106. package/skill-packages.lock.json +5 -0
  107. package/src/skills/builtin.ts +275 -0
  108. package/src/skills/shell.ts +118 -0
  109. package/src/skills/types.ts +30 -0
  110. package/src/types/api.ts +252 -0
  111. package/src/types/blessed-contrib.d.ts +4 -0
  112. package/src/types/context-budget.ts +76 -0
  113. package/src/types/doctor.ts +29 -0
  114. package/src/types/file-watcher.ts +26 -0
  115. package/src/types/incident.ts +57 -0
  116. package/src/types/local-state-backup.ts +121 -0
  117. package/src/types/mcp.ts +106 -0
  118. package/src/types/messaging.ts +35 -0
  119. package/src/types/model-routing.ts +61 -0
  120. package/src/types/mvp-gate.ts +99 -0
  121. package/src/types/orchestration.ts +65 -0
  122. package/src/types/persona-state.ts +61 -0
  123. package/src/types/policy.ts +27 -0
  124. package/src/types/reasoning-graph.ts +58 -0
  125. package/src/types/release.ts +115 -0
  126. package/src/types/reliability.ts +43 -0
  127. package/src/types/runtime-budget.ts +85 -0
  128. package/src/types/scheduler.ts +47 -0
  129. package/src/types/secret-vault.ts +62 -0
  130. package/src/types/skill-packages.ts +81 -0
  131. package/src/types/sqlite-vec.d.ts +5 -0
  132. package/src/types/websocket.ts +122 -0
@@ -0,0 +1,422 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { cancelOrchestrationJob, completeOrchestrationJob, createOrchestrationJob, failOrchestrationJob, markOrchestrationJobRunning, queueOrchestrationRetry, saveOrchestrationEvent, } from './db.js';
3
+ import { logThought } from '../utils/logger.js';
4
+ const DEFAULT_MAX_CONCURRENT_JOBS = 2;
5
+ const DEFAULT_JOB_TIMEOUT_MS = 20_000;
6
+ const DEFAULT_MAX_RETRY_ATTEMPTS = 1;
7
+ const DEFAULT_FAILURE_CIRCUIT_BREAKER_THRESHOLD = 3;
8
+ const DEFAULT_MAX_GRAPH_NODES = 8;
9
+ const DEFAULT_MAX_GRAPH_DEPTH = 4;
10
+ const ALLOWED_TRANSITIONS = {
11
+ queued: ['running', 'cancelled'],
12
+ running: ['queued', 'completed', 'failed', 'cancelled'],
13
+ completed: [],
14
+ failed: [],
15
+ cancelled: [],
16
+ };
17
+ export class OrchestrationService {
18
+ #maxConcurrentJobs;
19
+ #jobTimeoutMs;
20
+ #maxRetryAttempts;
21
+ #failureCircuitBreakerThreshold;
22
+ #maxGraphNodes;
23
+ #maxGraphDepth;
24
+ #jobs = new Map();
25
+ #consecutiveFailures = 0;
26
+ constructor(options = {}) {
27
+ this.#maxConcurrentJobs = Math.max(1, Number(options.maxConcurrentJobs ?? DEFAULT_MAX_CONCURRENT_JOBS));
28
+ this.#jobTimeoutMs = Math.max(1_000, Number(options.jobTimeoutMs ?? DEFAULT_JOB_TIMEOUT_MS));
29
+ this.#maxRetryAttempts = Math.max(0, Number(options.maxRetryAttempts ?? DEFAULT_MAX_RETRY_ATTEMPTS));
30
+ this.#failureCircuitBreakerThreshold = Math.max(1, Number(options.failureCircuitBreakerThreshold ?? DEFAULT_FAILURE_CIRCUIT_BREAKER_THRESHOLD));
31
+ this.#maxGraphNodes = Math.max(1, Number(options.maxGraphNodes ?? DEFAULT_MAX_GRAPH_NODES));
32
+ this.#maxGraphDepth = Math.max(1, Number(options.maxGraphDepth ?? DEFAULT_MAX_GRAPH_DEPTH));
33
+ }
34
+ observe(jobId) {
35
+ const job = this.#jobs.get(jobId);
36
+ return job ? this.#snapshot(job) : undefined;
37
+ }
38
+ cancel(jobId, reason = 'Cancelled by runtime operator.') {
39
+ const job = this.#jobs.get(jobId);
40
+ if (!job || this.#isTerminal(job.state)) {
41
+ return false;
42
+ }
43
+ job.abortController.abort();
44
+ this.#transition(job, 'cancelled', reason);
45
+ return true;
46
+ }
47
+ async runDelegation(request, executor) {
48
+ if (request.briefs.length === 0) {
49
+ return {
50
+ jobs: [],
51
+ summary: 'Delegation skipped: no briefs were provided.',
52
+ hasFailures: false,
53
+ };
54
+ }
55
+ if (this.#consecutiveFailures >= this.#failureCircuitBreakerThreshold) {
56
+ const summary = 'Delegation circuit-breaker is open after repeated failures; execution was blocked.';
57
+ await logThought(`[Orchestration] ${summary}`);
58
+ return {
59
+ jobs: [],
60
+ summary,
61
+ hasFailures: true,
62
+ };
63
+ }
64
+ const graph = this.#buildGraph(request);
65
+ await this.#runGraph(graph, request, executor);
66
+ const snapshots = graph.topologicalOrder
67
+ .map((nodeId) => graph.jobsByNodeId.get(nodeId))
68
+ .filter((job) => !!job)
69
+ .map((job) => this.#snapshot(job));
70
+ const hasFailures = snapshots.some((job) => job.state === 'failed' || job.state === 'cancelled');
71
+ const summary = this.#buildSummary(snapshots);
72
+ await logThought(`[Orchestration] Delegation finished for session '${request.sessionId}' with ${snapshots.length} job(s).`);
73
+ return {
74
+ jobs: snapshots,
75
+ summary,
76
+ hasFailures,
77
+ };
78
+ }
79
+ #createJob(request, brief) {
80
+ const timestamp = new Date().toISOString();
81
+ const job = {
82
+ id: randomUUID(),
83
+ nodeId: brief.id,
84
+ sessionId: request.sessionId,
85
+ parentMessage: request.parentMessage,
86
+ brief,
87
+ state: 'queued',
88
+ attempt: 1,
89
+ createdAt: timestamp,
90
+ updatedAt: timestamp,
91
+ abortController: new AbortController(),
92
+ };
93
+ this.#jobs.set(job.id, job);
94
+ createOrchestrationJob(job.id, job.sessionId, job.parentMessage, JSON.stringify(job.brief));
95
+ this.#recordEvent(job, 'queued', `Queued delegation job '${brief.title}'.`);
96
+ return job;
97
+ }
98
+ #buildGraph(request) {
99
+ if (request.briefs.length > this.#maxGraphNodes) {
100
+ throw new Error(`[Orchestration] Delegation graph has ${request.briefs.length} nodes, exceeding maxGraphNodes=${this.#maxGraphNodes}.`);
101
+ }
102
+ const briefsById = new Map();
103
+ const dependentsByNodeId = new Map();
104
+ const unmetDependencies = new Map();
105
+ for (const brief of request.briefs) {
106
+ const nodeId = brief.id.trim();
107
+ if (!nodeId) {
108
+ throw new Error('[Orchestration] Delegation brief id must be a non-empty string.');
109
+ }
110
+ if (briefsById.has(nodeId)) {
111
+ throw new Error(`[Orchestration] Duplicate delegation node id '${nodeId}'.`);
112
+ }
113
+ briefsById.set(nodeId, { ...brief, id: nodeId });
114
+ dependentsByNodeId.set(nodeId, []);
115
+ }
116
+ for (const brief of briefsById.values()) {
117
+ const deps = brief.dependsOn ?? [];
118
+ const normalizedDeps = deps.map((dep) => dep.trim()).filter(Boolean);
119
+ if (normalizedDeps.some((dep) => dep === brief.id)) {
120
+ throw new Error(`[Orchestration] Node '${brief.id}' cannot depend on itself.`);
121
+ }
122
+ for (const dep of normalizedDeps) {
123
+ if (!briefsById.has(dep)) {
124
+ throw new Error(`[Orchestration] Node '${brief.id}' depends on missing node '${dep}'.`);
125
+ }
126
+ dependentsByNodeId.get(dep)?.push(brief.id);
127
+ }
128
+ unmetDependencies.set(brief.id, new Set(normalizedDeps));
129
+ }
130
+ const topologicalOrder = this.#topologicalSort(briefsById, dependentsByNodeId, unmetDependencies);
131
+ if (topologicalOrder.length !== briefsById.size) {
132
+ throw new Error('[Orchestration] Delegation graph contains one or more dependency cycles.');
133
+ }
134
+ const depth = this.#computeGraphDepth(topologicalOrder, briefsById);
135
+ if (depth > this.#maxGraphDepth) {
136
+ throw new Error(`[Orchestration] Delegation graph depth ${depth} exceeds maxGraphDepth=${this.#maxGraphDepth}.`);
137
+ }
138
+ const jobsByNodeId = new Map();
139
+ for (const brief of briefsById.values()) {
140
+ const job = this.#createJob(request, brief);
141
+ jobsByNodeId.set(brief.id, job);
142
+ }
143
+ return {
144
+ jobsByNodeId,
145
+ dependentsByNodeId,
146
+ unmetDependencies,
147
+ pendingNodes: new Set(briefsById.keys()),
148
+ topologicalOrder,
149
+ };
150
+ }
151
+ #topologicalSort(briefsById, dependentsByNodeId, unmetDependencies) {
152
+ const inDegree = new Map();
153
+ for (const [nodeId, deps] of unmetDependencies) {
154
+ inDegree.set(nodeId, deps.size);
155
+ }
156
+ const queue = [...briefsById.keys()].filter((nodeId) => (inDegree.get(nodeId) ?? 0) === 0);
157
+ const ordered = [];
158
+ while (queue.length > 0) {
159
+ const nodeId = queue.shift();
160
+ if (!nodeId) {
161
+ continue;
162
+ }
163
+ ordered.push(nodeId);
164
+ const children = dependentsByNodeId.get(nodeId) ?? [];
165
+ for (const child of children) {
166
+ const next = (inDegree.get(child) ?? 0) - 1;
167
+ inDegree.set(child, next);
168
+ if (next === 0) {
169
+ queue.push(child);
170
+ }
171
+ }
172
+ }
173
+ return ordered;
174
+ }
175
+ #computeGraphDepth(order, briefsById) {
176
+ const depths = new Map();
177
+ let maxDepth = 0;
178
+ for (const nodeId of order) {
179
+ const deps = briefsById.get(nodeId)?.dependsOn ?? [];
180
+ const depth = deps.length === 0
181
+ ? 1
182
+ : Math.max(...deps.map((dep) => depths.get(dep) ?? 1)) + 1;
183
+ depths.set(nodeId, depth);
184
+ maxDepth = Math.max(maxDepth, depth);
185
+ }
186
+ return maxDepth;
187
+ }
188
+ async #runGraph(graph, request, executor) {
189
+ while (graph.pendingNodes.size > 0) {
190
+ if (this.#consecutiveFailures >= this.#failureCircuitBreakerThreshold) {
191
+ this.#cancelPendingGraphNodes(graph, 'Cancelled because orchestration circuit-breaker opened.');
192
+ return;
193
+ }
194
+ const readyJobs = this.#selectReadyJobs(graph);
195
+ if (readyJobs.length === 0) {
196
+ this.#cancelPendingGraphNodes(graph, 'Cancelled due to unresolved dependencies after upstream failures.');
197
+ return;
198
+ }
199
+ for (const batch of this.#chunkJobs(readyJobs, this.#maxConcurrentJobs)) {
200
+ const outcomes = await Promise.all(batch.map((job) => this.#executeGraphJob(job, request, executor)));
201
+ for (const outcome of outcomes) {
202
+ if (outcome.retryRequested) {
203
+ continue;
204
+ }
205
+ graph.pendingNodes.delete(outcome.job.nodeId);
206
+ if (outcome.job.state === 'completed') {
207
+ this.#releaseDependents(outcome.job.nodeId, graph);
208
+ continue;
209
+ }
210
+ if (outcome.job.state === 'failed' || outcome.job.state === 'cancelled') {
211
+ this.#cancelDependents(outcome.job.nodeId, graph, outcome.job.error ?? 'Upstream dependency failed.');
212
+ }
213
+ }
214
+ }
215
+ }
216
+ }
217
+ #selectReadyJobs(graph) {
218
+ const ready = [];
219
+ for (const nodeId of graph.pendingNodes) {
220
+ const job = graph.jobsByNodeId.get(nodeId);
221
+ if (!job || job.state !== 'queued') {
222
+ continue;
223
+ }
224
+ const unmet = graph.unmetDependencies.get(nodeId);
225
+ if (!unmet || unmet.size === 0) {
226
+ ready.push(job);
227
+ }
228
+ }
229
+ ready.sort((a, b) => a.brief.id.localeCompare(b.brief.id));
230
+ return ready;
231
+ }
232
+ async #executeGraphJob(job, request, executor) {
233
+ if (this.#consecutiveFailures >= this.#failureCircuitBreakerThreshold) {
234
+ this.#transition(job, 'cancelled', 'Cancelled because orchestration circuit-breaker opened.');
235
+ return { job, retryRequested: false };
236
+ }
237
+ const retryRequested = await this.#executeJob(job, request, executor);
238
+ return { job, retryRequested };
239
+ }
240
+ #releaseDependents(completedNodeId, graph) {
241
+ const dependents = graph.dependentsByNodeId.get(completedNodeId) ?? [];
242
+ for (const childNodeId of dependents) {
243
+ if (!graph.pendingNodes.has(childNodeId)) {
244
+ continue;
245
+ }
246
+ const unmet = graph.unmetDependencies.get(childNodeId);
247
+ if (!unmet || !unmet.has(completedNodeId)) {
248
+ continue;
249
+ }
250
+ unmet.delete(completedNodeId);
251
+ const childJob = graph.jobsByNodeId.get(childNodeId);
252
+ if (!childJob) {
253
+ continue;
254
+ }
255
+ if (unmet.size === 0) {
256
+ this.#recordEvent(childJob, 'queued', `All dependencies resolved for node '${childNodeId}'.`);
257
+ }
258
+ else {
259
+ this.#recordEvent(childJob, 'queued', `Dependency '${completedNodeId}' resolved for node '${childNodeId}'. Remaining: ${[...unmet].join(', ')}`);
260
+ }
261
+ }
262
+ }
263
+ #cancelDependents(failedNodeId, graph, reason) {
264
+ const queue = [...(graph.dependentsByNodeId.get(failedNodeId) ?? [])];
265
+ while (queue.length > 0) {
266
+ const nodeId = queue.shift();
267
+ if (!nodeId || !graph.pendingNodes.has(nodeId)) {
268
+ continue;
269
+ }
270
+ const job = graph.jobsByNodeId.get(nodeId);
271
+ if (!job || this.#isTerminal(job.state)) {
272
+ graph.pendingNodes.delete(nodeId);
273
+ continue;
274
+ }
275
+ this.#transition(job, 'cancelled', `Cancelled because dependency '${failedNodeId}' did not complete successfully. ${reason}`);
276
+ graph.pendingNodes.delete(nodeId);
277
+ const descendants = graph.dependentsByNodeId.get(nodeId) ?? [];
278
+ queue.push(...descendants);
279
+ }
280
+ }
281
+ #cancelPendingGraphNodes(graph, reason) {
282
+ for (const nodeId of [...graph.pendingNodes]) {
283
+ const job = graph.jobsByNodeId.get(nodeId);
284
+ if (!job || this.#isTerminal(job.state)) {
285
+ graph.pendingNodes.delete(nodeId);
286
+ continue;
287
+ }
288
+ this.#transition(job, 'cancelled', reason);
289
+ graph.pendingNodes.delete(nodeId);
290
+ }
291
+ }
292
+ #chunkJobs(jobs, size) {
293
+ const chunks = [];
294
+ for (let index = 0; index < jobs.length; index += size) {
295
+ chunks.push(jobs.slice(index, index + size));
296
+ }
297
+ return chunks;
298
+ }
299
+ async #executeJob(job, request, executor) {
300
+ this.#transition(job, 'running', `Starting attempt ${job.attempt}.`);
301
+ try {
302
+ const output = await this.#executeWithTimeout(job, request, executor);
303
+ this.#transition(job, 'completed', `Attempt ${job.attempt} completed.`, output);
304
+ this.#consecutiveFailures = 0;
305
+ return false;
306
+ }
307
+ catch (error) {
308
+ const message = error instanceof Error ? error.message : String(error);
309
+ const canRetry = job.attempt <= this.#maxRetryAttempts;
310
+ if (canRetry) {
311
+ job.attempt += 1;
312
+ this.#transition(job, 'queued', `Attempt failed; retrying. Reason: ${message}`);
313
+ return true;
314
+ }
315
+ this.#transition(job, 'failed', `Attempt ${job.attempt} failed: ${message}`);
316
+ this.#consecutiveFailures += 1;
317
+ return false;
318
+ }
319
+ }
320
+ async #executeWithTimeout(job, request, executor) {
321
+ const timeoutMs = job.brief.constraints.timeoutMs > 0
322
+ ? job.brief.constraints.timeoutMs
323
+ : this.#jobTimeoutMs;
324
+ let timeoutHandle = null;
325
+ const timeoutPromise = new Promise((_, reject) => {
326
+ timeoutHandle = setTimeout(() => {
327
+ job.abortController.abort();
328
+ reject(new Error(`Delegated job timed out after ${timeoutMs}ms.`));
329
+ }, timeoutMs);
330
+ });
331
+ try {
332
+ const result = await Promise.race([
333
+ executor({
334
+ request,
335
+ job: this.#snapshot(job),
336
+ signal: job.abortController.signal,
337
+ }),
338
+ timeoutPromise,
339
+ ]);
340
+ return result;
341
+ }
342
+ finally {
343
+ if (timeoutHandle) {
344
+ clearTimeout(timeoutHandle);
345
+ }
346
+ }
347
+ }
348
+ #transition(job, nextState, detail, output) {
349
+ const allowed = ALLOWED_TRANSITIONS[job.state];
350
+ if (!allowed.includes(nextState)) {
351
+ throw new Error(`[Orchestration] Illegal transition ${job.state} -> ${nextState} for job ${job.id}.`);
352
+ }
353
+ job.state = nextState;
354
+ job.updatedAt = new Date().toISOString();
355
+ if (nextState === 'running') {
356
+ job.startedAt = job.startedAt ?? job.updatedAt;
357
+ markOrchestrationJobRunning(job.id, job.attempt);
358
+ }
359
+ else if (nextState === 'completed') {
360
+ job.completedAt = job.updatedAt;
361
+ job.output = output ?? '';
362
+ completeOrchestrationJob(job.id, job.output);
363
+ }
364
+ else if (nextState === 'failed') {
365
+ job.completedAt = job.updatedAt;
366
+ job.error = detail;
367
+ failOrchestrationJob(job.id, detail);
368
+ }
369
+ else if (nextState === 'cancelled') {
370
+ job.completedAt = job.updatedAt;
371
+ job.error = detail;
372
+ cancelOrchestrationJob(job.id, detail);
373
+ }
374
+ else {
375
+ queueOrchestrationRetry(job.id, job.attempt, detail);
376
+ }
377
+ this.#recordEvent(job, nextState, detail);
378
+ }
379
+ #recordEvent(job, state, detail) {
380
+ saveOrchestrationEvent({
381
+ id: randomUUID(),
382
+ jobId: job.id,
383
+ sessionId: job.sessionId,
384
+ state,
385
+ detail,
386
+ });
387
+ }
388
+ #snapshot(job) {
389
+ return {
390
+ id: job.id,
391
+ sessionId: job.sessionId,
392
+ parentMessage: job.parentMessage,
393
+ brief: job.brief,
394
+ state: job.state,
395
+ attempt: job.attempt,
396
+ createdAt: job.createdAt,
397
+ updatedAt: job.updatedAt,
398
+ startedAt: job.startedAt,
399
+ completedAt: job.completedAt,
400
+ output: job.output,
401
+ error: job.error,
402
+ };
403
+ }
404
+ #buildSummary(jobs) {
405
+ const completed = jobs.filter((job) => job.state === 'completed').length;
406
+ const failed = jobs.filter((job) => job.state === 'failed').length;
407
+ const cancelled = jobs.filter((job) => job.state === 'cancelled').length;
408
+ const lines = jobs.map((job) => {
409
+ const suffix = job.state === 'completed'
410
+ ? (job.output ?? '').slice(0, 280)
411
+ : (job.error ?? 'No error detail captured.');
412
+ return `- ${job.brief.title} [${job.state}] ${suffix}`;
413
+ });
414
+ return [
415
+ `Delegation summary: ${completed} completed, ${failed} failed, ${cancelled} cancelled.`,
416
+ ...lines,
417
+ ].join('\n');
418
+ }
419
+ #isTerminal(state) {
420
+ return state === 'completed' || state === 'failed' || state === 'cancelled';
421
+ }
422
+ }
@@ -0,0 +1,256 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { access, copyFile, mkdir, readFile, rename, rm, stat, writeFile, } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { logThought } from '../utils/logger.js';
5
+ import { PERSONA_DOCUMENT_KEYS, PersonaConflictError, PersonaValidationError, } from '../types/persona-state.js';
6
+ const MAX_PERSONA_FIELD_LENGTH = 120_000;
7
+ const DEFAULT_FS_ADAPTER = {
8
+ access,
9
+ copyFile,
10
+ mkdir: async (targetPath, options) => {
11
+ await mkdir(targetPath, options);
12
+ },
13
+ readFile,
14
+ rename,
15
+ rm: async (targetPath, options) => {
16
+ await rm(targetPath, { force: options?.force ?? false });
17
+ },
18
+ stat,
19
+ writeFile,
20
+ };
21
+ export class PersonaStateService {
22
+ #identityDir;
23
+ #now;
24
+ #fs;
25
+ #auditLogger;
26
+ constructor(options = {}) {
27
+ this.#identityDir = options.identityDir ?? path.resolve('identity');
28
+ this.#now = options.now ?? (() => new Date());
29
+ this.#fs = options.fsAdapter ?? DEFAULT_FS_ADAPTER;
30
+ this.#auditLogger = options.auditLogger ?? logThought;
31
+ }
32
+ async getState() {
33
+ await this.#fs.mkdir(this.#identityDir, { recursive: true });
34
+ const soul = await this.#readDocument('soul');
35
+ const identity = await this.#readDocument('identity');
36
+ const user = await this.#readDocument('user');
37
+ const revision = this.#computeRevision({
38
+ soul: soul.content,
39
+ identity: identity.content,
40
+ user: user.content,
41
+ });
42
+ const latestUpdatedAtMs = Math.max(soul.updatedAtMs, identity.updatedAtMs, user.updatedAtMs);
43
+ return {
44
+ revision,
45
+ updatedAt: latestUpdatedAtMs > 0
46
+ ? new Date(latestUpdatedAtMs).toISOString()
47
+ : this.#now().toISOString(),
48
+ soul: soul.content,
49
+ identity: identity.content,
50
+ user: user.content,
51
+ };
52
+ }
53
+ async updateState(input) {
54
+ const normalizedInput = this.#validateUpdateInput(input);
55
+ const currentState = await this.getState();
56
+ if (normalizedInput.expectedRevision !== currentState.revision) {
57
+ throw new PersonaConflictError(currentState.revision);
58
+ }
59
+ const nextState = {
60
+ soul: normalizedInput.soul,
61
+ identity: normalizedInput.identity,
62
+ user: normalizedInput.user,
63
+ };
64
+ const changedDocuments = PERSONA_DOCUMENT_KEYS.filter((key) => {
65
+ return currentState[key] !== nextState[key];
66
+ });
67
+ if (changedDocuments.length === 0) {
68
+ return {
69
+ state: currentState,
70
+ diagnostics: {
71
+ outcome: 'noop',
72
+ changedDocuments: [],
73
+ warnings: [],
74
+ },
75
+ };
76
+ }
77
+ const plans = [];
78
+ const warnings = [];
79
+ for (const key of changedDocuments) {
80
+ const targetPath = this.#documentPath(key);
81
+ const tempPath = `${targetPath}.tmp-${randomUUID()}`;
82
+ const backupPath = `${targetPath}.bak-${randomUUID()}`;
83
+ await this.#fs.writeFile(tempPath, nextState[key], 'utf8');
84
+ const hadOriginal = await this.#fileExists(targetPath);
85
+ if (hadOriginal) {
86
+ await this.#fs.copyFile(targetPath, backupPath);
87
+ }
88
+ plans.push({
89
+ key,
90
+ targetPath,
91
+ tempPath,
92
+ backupPath,
93
+ hadOriginal,
94
+ applied: false,
95
+ originalRemoved: false,
96
+ });
97
+ }
98
+ try {
99
+ for (const plan of plans) {
100
+ await this.#removePathIfExists(plan.targetPath);
101
+ plan.originalRemoved = true;
102
+ await this.#fs.rename(plan.tempPath, plan.targetPath);
103
+ plan.applied = true;
104
+ }
105
+ for (const plan of plans) {
106
+ warnings.push(...(await this.#cleanupPath(plan.backupPath)));
107
+ }
108
+ const updatedState = await this.getState();
109
+ await this.#auditLogger(`[PersonaState] Updated documents: ${changedDocuments.join(', ')} | revision=${updatedState.revision}`);
110
+ return {
111
+ state: updatedState,
112
+ diagnostics: {
113
+ outcome: 'updated',
114
+ changedDocuments,
115
+ warnings,
116
+ },
117
+ };
118
+ }
119
+ catch (error) {
120
+ const rollbackWarnings = await this.#rollbackPlans(plans);
121
+ const errorMessage = error instanceof Error ? error.message : String(error);
122
+ const warningSuffix = rollbackWarnings.length > 0 ? ` | Rollback warnings: ${rollbackWarnings.join(' | ')}` : '';
123
+ await this.#auditLogger(`[PersonaState] Update failed; rollback applied. ${errorMessage}${warningSuffix}`);
124
+ throw new Error(`Persona state update failed; rollback applied. ${errorMessage}${warningSuffix}`);
125
+ }
126
+ finally {
127
+ for (const plan of plans) {
128
+ warnings.push(...(await this.#cleanupPath(plan.tempPath)));
129
+ warnings.push(...(await this.#cleanupPath(plan.backupPath)));
130
+ }
131
+ }
132
+ }
133
+ #validateUpdateInput(input) {
134
+ const hints = [];
135
+ const isValidRevision = typeof input.expectedRevision === 'string' && input.expectedRevision.trim().length > 0;
136
+ if (!isValidRevision) {
137
+ hints.push("'expectedRevision' must be a non-empty string.");
138
+ }
139
+ const soul = this.#validateField('soul', input.soul, hints);
140
+ const identity = this.#validateField('identity', input.identity, hints);
141
+ const user = this.#validateField('user', input.user, hints);
142
+ if (hints.length > 0) {
143
+ throw new PersonaValidationError(hints);
144
+ }
145
+ // isValidRevision is true here (would have thrown otherwise)
146
+ return {
147
+ expectedRevision: input.expectedRevision,
148
+ soul,
149
+ identity,
150
+ user,
151
+ };
152
+ }
153
+ #validateField(name, value, hints) {
154
+ if (typeof value !== 'string') {
155
+ hints.push(`'${name}' must be a string.`);
156
+ return '';
157
+ }
158
+ if (value.length > MAX_PERSONA_FIELD_LENGTH) {
159
+ hints.push(`'${name}' exceeds ${MAX_PERSONA_FIELD_LENGTH} characters.`);
160
+ }
161
+ return value;
162
+ }
163
+ async #rollbackPlans(plans) {
164
+ const warnings = [];
165
+ const reversePlans = [...plans].reverse();
166
+ for (const plan of reversePlans) {
167
+ if (plan.applied || plan.originalRemoved) {
168
+ if (plan.hadOriginal) {
169
+ warnings.push(...(await this.#removePathIfExists(plan.targetPath)));
170
+ try {
171
+ await this.#fs.rename(plan.backupPath, plan.targetPath);
172
+ }
173
+ catch (error) {
174
+ warnings.push(`Failed to restore backup for '${plan.key}': ${error instanceof Error ? error.message : String(error)}`);
175
+ }
176
+ }
177
+ else {
178
+ warnings.push(...(await this.#removePathIfExists(plan.targetPath)));
179
+ }
180
+ }
181
+ warnings.push(...(await this.#cleanupPath(plan.tempPath)));
182
+ warnings.push(...(await this.#cleanupPath(plan.backupPath)));
183
+ }
184
+ return warnings;
185
+ }
186
+ async #readDocument(key) {
187
+ const filePath = this.#documentPath(key);
188
+ try {
189
+ const [content, metadata] = await Promise.all([
190
+ this.#fs.readFile(filePath, 'utf8'),
191
+ this.#fs.stat(filePath),
192
+ ]);
193
+ return {
194
+ content,
195
+ updatedAtMs: Number.isFinite(metadata.mtimeMs) ? metadata.mtimeMs : 0,
196
+ };
197
+ }
198
+ catch (error) {
199
+ if (this.#isNotFound(error)) {
200
+ return { content: '', updatedAtMs: 0 };
201
+ }
202
+ throw error;
203
+ }
204
+ }
205
+ #computeRevision(value) {
206
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
207
+ }
208
+ #documentPath(key) {
209
+ return path.join(this.#identityDir, `${key}.md`);
210
+ }
211
+ async #fileExists(targetPath) {
212
+ try {
213
+ await this.#fs.access(targetPath);
214
+ return true;
215
+ }
216
+ catch (error) {
217
+ if (this.#isNotFound(error)) {
218
+ return false;
219
+ }
220
+ throw error;
221
+ }
222
+ }
223
+ async #removePathIfExists(targetPath) {
224
+ try {
225
+ await this.#fs.rm(targetPath, { force: true });
226
+ return [];
227
+ }
228
+ catch (error) {
229
+ if (this.#isNotFound(error)) {
230
+ return [];
231
+ }
232
+ return [
233
+ `Failed to remove '${targetPath}': ${error instanceof Error ? error.message : String(error)}`,
234
+ ];
235
+ }
236
+ }
237
+ async #cleanupPath(targetPath) {
238
+ return this.#removePathIfExists(targetPath);
239
+ }
240
+ #isNotFound(error) {
241
+ return (typeof error === 'object' &&
242
+ error !== null &&
243
+ 'code' in error &&
244
+ error.code === 'ENOENT');
245
+ }
246
+ }
247
+ let defaultPersonaStateService = null;
248
+ export function getPersonaStateService() {
249
+ if (!defaultPersonaStateService) {
250
+ defaultPersonaStateService = new PersonaStateService();
251
+ }
252
+ return defaultPersonaStateService;
253
+ }
254
+ export function resetPersonaStateServiceForTests() {
255
+ defaultPersonaStateService = null;
256
+ }