instar 0.28.83 → 0.28.85
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/dashboard/index.html +239 -0
- package/dist/cli.js +10 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/intent.d.ts.map +1 -1
- package/dist/commands/intent.js +29 -2
- package/dist/commands/intent.js.map +1 -1
- package/dist/commands/memoryBackfillEvidence.d.ts +69 -0
- package/dist/commands/memoryBackfillEvidence.d.ts.map +1 -0
- package/dist/commands/memoryBackfillEvidence.js +220 -0
- package/dist/commands/memoryBackfillEvidence.js.map +1 -0
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +86 -1
- package/dist/commands/server.js.map +1 -1
- package/dist/core/DecisionJournal.d.ts +66 -3
- package/dist/core/DecisionJournal.d.ts.map +1 -1
- package/dist/core/DecisionJournal.js +116 -3
- package/dist/core/DecisionJournal.js.map +1 -1
- package/dist/core/DispatchExecutor.d.ts +74 -1
- package/dist/core/DispatchExecutor.d.ts.map +1 -1
- package/dist/core/DispatchExecutor.js +120 -1
- package/dist/core/DispatchExecutor.js.map +1 -1
- package/dist/core/EvolutionManager.d.ts +133 -0
- package/dist/core/EvolutionManager.d.ts.map +1 -1
- package/dist/core/EvolutionManager.js +494 -0
- package/dist/core/EvolutionManager.js.map +1 -1
- package/dist/core/InitiativeTracker.d.ts +82 -15
- package/dist/core/InitiativeTracker.d.ts.map +1 -1
- package/dist/core/InitiativeTracker.js +502 -31
- package/dist/core/InitiativeTracker.js.map +1 -1
- package/dist/core/LearnSkillBridge.d.ts +119 -0
- package/dist/core/LearnSkillBridge.d.ts.map +1 -0
- package/dist/core/LearnSkillBridge.js +160 -0
- package/dist/core/LearnSkillBridge.js.map +1 -0
- package/dist/core/SharedStateLedger.d.ts.map +1 -1
- package/dist/core/SharedStateLedger.js +4 -0
- package/dist/core/SharedStateLedger.js.map +1 -1
- package/dist/core/types.d.ts +124 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +8 -0
- package/dist/core/types.js.map +1 -1
- package/dist/memory/EvidenceRenderer.d.ts +64 -0
- package/dist/memory/EvidenceRenderer.d.ts.map +1 -0
- package/dist/memory/EvidenceRenderer.js +119 -0
- package/dist/memory/EvidenceRenderer.js.map +1 -0
- package/dist/memory/SemanticMemory.d.ts +61 -1
- package/dist/memory/SemanticMemory.d.ts.map +1 -1
- package/dist/memory/SemanticMemory.js +381 -11
- package/dist/memory/SemanticMemory.js.map +1 -1
- package/dist/server/AgentServer.d.ts +4 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +2 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/routes.d.ts +8 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +500 -14
- package/dist/server/routes.js.map +1 -1
- package/dist/tasks/DivergenceChecker.d.ts +89 -0
- package/dist/tasks/DivergenceChecker.d.ts.map +1 -0
- package/dist/tasks/DivergenceChecker.js +255 -0
- package/dist/tasks/DivergenceChecker.js.map +1 -0
- package/dist/tasks/LruCache.d.ts +33 -0
- package/dist/tasks/LruCache.d.ts.map +1 -0
- package/dist/tasks/LruCache.js +88 -0
- package/dist/tasks/LruCache.js.map +1 -0
- package/dist/tasks/RateLimiter.d.ts +61 -0
- package/dist/tasks/RateLimiter.d.ts.map +1 -0
- package/dist/tasks/RateLimiter.js +108 -0
- package/dist/tasks/RateLimiter.js.map +1 -0
- package/dist/tasks/TaskFlowDueWaker.d.ts +29 -0
- package/dist/tasks/TaskFlowDueWaker.d.ts.map +1 -0
- package/dist/tasks/TaskFlowDueWaker.js +73 -0
- package/dist/tasks/TaskFlowDueWaker.js.map +1 -0
- package/dist/tasks/TaskFlowMaintenanceSweeper.d.ts +40 -0
- package/dist/tasks/TaskFlowMaintenanceSweeper.d.ts.map +1 -0
- package/dist/tasks/TaskFlowMaintenanceSweeper.js +131 -0
- package/dist/tasks/TaskFlowMaintenanceSweeper.js.map +1 -0
- package/dist/tasks/TaskFlowRegistry.d.ts +180 -0
- package/dist/tasks/TaskFlowRegistry.d.ts.map +1 -0
- package/dist/tasks/TaskFlowRegistry.js +791 -0
- package/dist/tasks/TaskFlowRegistry.js.map +1 -0
- package/dist/tasks/ThreadlineFlowBridge.d.ts +54 -0
- package/dist/tasks/ThreadlineFlowBridge.d.ts.map +1 -0
- package/dist/tasks/ThreadlineFlowBridge.js +129 -0
- package/dist/tasks/ThreadlineFlowBridge.js.map +1 -0
- package/dist/tasks/task-flow-registry.store.sqlite.d.ts +77 -0
- package/dist/tasks/task-flow-registry.store.sqlite.d.ts.map +1 -0
- package/dist/tasks/task-flow-registry.store.sqlite.js +350 -0
- package/dist/tasks/task-flow-registry.store.sqlite.js.map +1 -0
- package/dist/tasks/task-flow-types.d.ts +229 -0
- package/dist/tasks/task-flow-types.d.ts.map +1 -0
- package/dist/tasks/task-flow-types.js +128 -0
- package/dist/tasks/task-flow-types.js.map +1 -0
- package/dist/threadline/ThreadlineMCPServer.d.ts +32 -0
- package/dist/threadline/ThreadlineMCPServer.d.ts.map +1 -1
- package/dist/threadline/ThreadlineMCPServer.js +82 -5
- package/dist/threadline/ThreadlineMCPServer.js.map +1 -1
- package/dist/threadline/mcp-stdio-entry.js +56 -0
- package/dist/threadline/mcp-stdio-entry.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +78 -78
- package/upgrades/0.28.84.md +89 -0
- package/upgrades/0.28.85.md +64 -0
- package/upgrades/side-effects/0.28.83.md +27 -0
- package/upgrades/side-effects/taskflow-phase1.md +130 -0
- package/upgrades/side-effects/taskflow-phase2.md +110 -0
- package/upgrades/side-effects/taskflow-phase3a.md +138 -0
- package/upgrades/side-effects/taskflow-phase4.md +377 -0
- package/upgrades/side-effects/taskflow-phase5.md +234 -0
- package/upgrades/side-effects/threadline-discover-relay-and-trust.md +122 -0
- package/upgrades/side-effects/wikiclaim-evidence-phase1.md +138 -0
- package/upgrades/side-effects/wikiclaim-evidence-phase2.md +148 -0
- package/upgrades/side-effects/wikiclaim-evidence-phase3.md +125 -0
- package/upgrades/side-effects/wikiclaim-evidence-phase4.md +129 -0
- package/upgrades/side-effects/wikiclaim-evidence-phase5.md +285 -0
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TaskFlowRegistry — durable, optimistic-concurrency multi-step job records.
|
|
3
|
+
*
|
|
4
|
+
* Imported (trimmed) from OpenClaw's task-flow-registry. See
|
|
5
|
+
* `docs/specs/OPENCLAW-IMPORT-TASKFLOW-SPEC.md` for full design.
|
|
6
|
+
*
|
|
7
|
+
* v1 constraints (deliberate):
|
|
8
|
+
* - Single writer (the instar server process).
|
|
9
|
+
* - `managed` sync mode only.
|
|
10
|
+
* - No `blocked` status (overlapped `waiting`).
|
|
11
|
+
* - Notification dispatch is wired but stubbed at metric-emission only;
|
|
12
|
+
* Phase 5 wires through `TelegramAdapter` send.
|
|
13
|
+
*/
|
|
14
|
+
import { EventEmitter } from 'node:events';
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
import { RateLimiter } from './RateLimiter.js';
|
|
17
|
+
import { LruCache } from './LruCache.js';
|
|
18
|
+
import { TaskFlowError, createFlowInputSchema, waitJsonSchema, isTerminalStatus, jsonByteLength, MAX_STATE_JSON_BYTES, MAX_WAIT_JSON_BYTES, MAX_CURRENT_STEP_CHARS, DEFAULT_THRESHOLDS, DEFAULT_RATE_LIMITS, DEFAULT_CACHE_CONFIG, ACTIVE_STATUSES, RESERVED_MAINTENANCE_CONTROLLER, } from './task-flow-types.js';
|
|
19
|
+
export class TaskFlowRegistry extends EventEmitter {
|
|
20
|
+
store;
|
|
21
|
+
ledger;
|
|
22
|
+
thresholds;
|
|
23
|
+
rateLimits;
|
|
24
|
+
cacheConfig;
|
|
25
|
+
now;
|
|
26
|
+
cache;
|
|
27
|
+
createLimiter;
|
|
28
|
+
pingLimiter;
|
|
29
|
+
/** Per-controller in-memory active count cache; populated lazily from DB. */
|
|
30
|
+
activeCountByController = new Map();
|
|
31
|
+
/** Set of controllerIds whose count has been loaded from DB at least once. */
|
|
32
|
+
activeCountLoaded = new Set();
|
|
33
|
+
notifyMetrics = { emitted: 0, byKind: {} };
|
|
34
|
+
/** Counter for cache evictions (Prometheus-style metric source). */
|
|
35
|
+
cacheEvictionsTotal = 0;
|
|
36
|
+
/** Counter for rate-limit rejections, broken out by kind. */
|
|
37
|
+
rateLimitRejections = {
|
|
38
|
+
create_rate: 0,
|
|
39
|
+
max_active: 0,
|
|
40
|
+
ping_rate: 0,
|
|
41
|
+
};
|
|
42
|
+
constructor(opts) {
|
|
43
|
+
super();
|
|
44
|
+
this.store = opts.store;
|
|
45
|
+
this.ledger = opts.ledger;
|
|
46
|
+
this.thresholds = { ...DEFAULT_THRESHOLDS, ...(opts.thresholds ?? {}) };
|
|
47
|
+
this.rateLimits = { ...DEFAULT_RATE_LIMITS, ...(opts.rateLimits ?? {}) };
|
|
48
|
+
this.cacheConfig = { ...DEFAULT_CACHE_CONFIG, ...(opts.cache ?? {}) };
|
|
49
|
+
this.now = opts.now ?? (() => Date.now());
|
|
50
|
+
this.cache = new LruCache({
|
|
51
|
+
maxEntries: this.cacheConfig.maxEntries,
|
|
52
|
+
onEvict: (_k, _v) => {
|
|
53
|
+
this.cacheEvictionsTotal++;
|
|
54
|
+
this.emitCacheEvictionMetric();
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
this.createLimiter = new RateLimiter({
|
|
58
|
+
windowMs: 1000,
|
|
59
|
+
limit: this.rateLimits.createPerSecPerController,
|
|
60
|
+
now: this.now,
|
|
61
|
+
});
|
|
62
|
+
this.pingLimiter = new RateLimiter({
|
|
63
|
+
windowMs: 60_000,
|
|
64
|
+
limit: this.rateLimits.pingPerMinPerFlow,
|
|
65
|
+
now: this.now,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
getThresholds() {
|
|
69
|
+
return { ...this.thresholds };
|
|
70
|
+
}
|
|
71
|
+
getRateLimits() {
|
|
72
|
+
return { ...this.rateLimits };
|
|
73
|
+
}
|
|
74
|
+
getCacheConfig() {
|
|
75
|
+
return { ...this.cacheConfig };
|
|
76
|
+
}
|
|
77
|
+
/** Test-only: returns the LRU cache size. */
|
|
78
|
+
get cacheSize() {
|
|
79
|
+
return this.cache.size;
|
|
80
|
+
}
|
|
81
|
+
// ────────────────── reads ──────────────────
|
|
82
|
+
getFlow(flowId, opts = {}) {
|
|
83
|
+
if (!opts.bypassCache) {
|
|
84
|
+
const cached = this.cache.get(flowId);
|
|
85
|
+
if (cached)
|
|
86
|
+
return cached;
|
|
87
|
+
}
|
|
88
|
+
const rec = this.store.getFlow(flowId);
|
|
89
|
+
if (rec)
|
|
90
|
+
this.cachePut(rec);
|
|
91
|
+
return rec ?? null;
|
|
92
|
+
}
|
|
93
|
+
/** Returns the redacted view safe to expose to non-owning callers. */
|
|
94
|
+
getRedactedFlow(flowId) {
|
|
95
|
+
const rec = this.getFlow(flowId);
|
|
96
|
+
if (!rec)
|
|
97
|
+
return null;
|
|
98
|
+
return {
|
|
99
|
+
flowId: rec.flowId,
|
|
100
|
+
ownerKey: rec.ownerKey,
|
|
101
|
+
controllerId: rec.controllerId,
|
|
102
|
+
revision: rec.revision,
|
|
103
|
+
status: rec.status,
|
|
104
|
+
goal: rec.goal,
|
|
105
|
+
currentStep: rec.currentStep,
|
|
106
|
+
createdAt: rec.createdAt,
|
|
107
|
+
updatedAt: rec.updatedAt,
|
|
108
|
+
endedAt: rec.endedAt,
|
|
109
|
+
// wait_kind only — never the identifying fields inside waitJson
|
|
110
|
+
waitJson: rec.waitJson ? { kind: rec.waitJson.kind } : undefined,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
findWaitingByReply(args) {
|
|
114
|
+
return this.store
|
|
115
|
+
.findWaitingReplyByTarget(args)
|
|
116
|
+
.filter((r) => r.waitInstanceId)
|
|
117
|
+
.map((r) => this.toWaitMatch(r));
|
|
118
|
+
}
|
|
119
|
+
findWaitingByCorrelation(args) {
|
|
120
|
+
return this.store
|
|
121
|
+
.findWaitingByCorrelation(args)
|
|
122
|
+
.filter((r) => r.waitInstanceId)
|
|
123
|
+
.map((r) => this.toWaitMatch(r));
|
|
124
|
+
}
|
|
125
|
+
findWaitingByDueAt(nowMs) {
|
|
126
|
+
return this.store
|
|
127
|
+
.findWaitingDue(nowMs)
|
|
128
|
+
.filter((r) => r.waitInstanceId)
|
|
129
|
+
.map((r) => this.toWaitMatch(r));
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* List all flows for a controllerId, optionally filtered by status.
|
|
133
|
+
* Used by DivergenceChecker — read-only, not on the hot mutation path.
|
|
134
|
+
*/
|
|
135
|
+
findByControllerId(controllerId, opts = {}) {
|
|
136
|
+
return this.store.findByControllerId(controllerId, opts);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Look up a flow by (controllerId, ownerKey, idempotencyKey). Returns null
|
|
140
|
+
* if no matching record. Useful for migrate-existing backfill and for tests.
|
|
141
|
+
*/
|
|
142
|
+
findByIdempotency(controllerId, ownerKey, idempotencyKey) {
|
|
143
|
+
return this.store.findIdempotent(controllerId, ownerKey, idempotencyKey);
|
|
144
|
+
}
|
|
145
|
+
// ────────────────── createFlow ──────────────────
|
|
146
|
+
async createFlow(input) {
|
|
147
|
+
const parsed = createFlowInputSchema.safeParse(input);
|
|
148
|
+
if (!parsed.success) {
|
|
149
|
+
const issue = parsed.error.issues[0];
|
|
150
|
+
throw new TaskFlowError('invalid_argument', `${issue.path.join('.')}: ${issue.message}`, { field: issue.path.join('.'), reason: issue.message });
|
|
151
|
+
}
|
|
152
|
+
if (parsed.data.controllerId === RESERVED_MAINTENANCE_CONTROLLER) {
|
|
153
|
+
throw new TaskFlowError('unauthorized_controller', 'TaskFlowMaintenance is a reserved controllerId; createFlow rejected', { actual: parsed.data.controllerId });
|
|
154
|
+
}
|
|
155
|
+
if (parsed.data.stateJson !== undefined) {
|
|
156
|
+
this.validateStateJsonSize(parsed.data.stateJson);
|
|
157
|
+
}
|
|
158
|
+
// ── Phase 5 rate limits ────────────────────────────────────────────
|
|
159
|
+
// Idempotent-replay carve-out: a duplicate idempotencyKey replay must
|
|
160
|
+
// NOT count against the per-second rate limit. Check idempotency in a
|
|
161
|
+
// cheap read first; if the flow exists, short-circuit before counting.
|
|
162
|
+
const preExisting = this.store.findIdempotent(parsed.data.controllerId, parsed.data.ownerKey, parsed.data.idempotencyKey);
|
|
163
|
+
if (preExisting)
|
|
164
|
+
return { flow: preExisting, created: false };
|
|
165
|
+
const rateRes = this.createLimiter.tryAcquire(parsed.data.controllerId);
|
|
166
|
+
if (!rateRes.ok) {
|
|
167
|
+
this.rateLimitRejections.create_rate++;
|
|
168
|
+
throw new TaskFlowError('quota_exceeded', `createFlow rate limit exceeded for controllerId=${parsed.data.controllerId}`, {
|
|
169
|
+
code: 'rate_limit',
|
|
170
|
+
scope: 'create',
|
|
171
|
+
limit: this.rateLimits.createPerSecPerController,
|
|
172
|
+
windowMs: 1000,
|
|
173
|
+
retryAfterMs: rateRes.retryAfterMs ?? 1000,
|
|
174
|
+
controllerId: parsed.data.controllerId,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
const active = this.getActiveCount(parsed.data.controllerId);
|
|
178
|
+
if (active >= this.rateLimits.maxActivePerController) {
|
|
179
|
+
this.rateLimitRejections.max_active++;
|
|
180
|
+
throw new TaskFlowError('quota_exceeded', `max active flows exceeded for controllerId=${parsed.data.controllerId}`, {
|
|
181
|
+
code: 'max_active',
|
|
182
|
+
scope: 'create',
|
|
183
|
+
limit: this.rateLimits.maxActivePerController,
|
|
184
|
+
currentActive: active,
|
|
185
|
+
controllerId: parsed.data.controllerId,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return this.store.withWriteTransaction(() => {
|
|
189
|
+
// Re-check idempotency inside the transaction (race-safe).
|
|
190
|
+
const existing = this.store.findIdempotent(parsed.data.controllerId, parsed.data.ownerKey, parsed.data.idempotencyKey);
|
|
191
|
+
if (existing)
|
|
192
|
+
return { flow: existing, created: false };
|
|
193
|
+
const ts = this.now();
|
|
194
|
+
const rec = {
|
|
195
|
+
flowId: this.genId(),
|
|
196
|
+
ownerKey: parsed.data.ownerKey,
|
|
197
|
+
controllerId: parsed.data.controllerId,
|
|
198
|
+
controllerInstanceId: parsed.data.controllerInstanceId,
|
|
199
|
+
controllerHeartbeatAt: ts,
|
|
200
|
+
revision: 1,
|
|
201
|
+
status: 'queued',
|
|
202
|
+
notifyPolicy: parsed.data.notifyPolicy ?? { kind: 'silent' },
|
|
203
|
+
goal: parsed.data.goal,
|
|
204
|
+
currentStep: parsed.data.currentStep,
|
|
205
|
+
stateJson: parsed.data.stateJson,
|
|
206
|
+
requesterOrigin: parsed.data.requesterOrigin,
|
|
207
|
+
privacyScope: parsed.data.privacyScope,
|
|
208
|
+
createdAt: ts,
|
|
209
|
+
updatedAt: ts,
|
|
210
|
+
};
|
|
211
|
+
this.store.insertFlow(rec, parsed.data.idempotencyKey);
|
|
212
|
+
this.cachePut(rec);
|
|
213
|
+
// bump in-memory active count cache; if not yet loaded, leave it for the
|
|
214
|
+
// next getActiveCount to load lazily.
|
|
215
|
+
if (this.activeCountLoaded.has(parsed.data.controllerId)) {
|
|
216
|
+
this.activeCountByController.set(parsed.data.controllerId, (this.activeCountByController.get(parsed.data.controllerId) ?? 0) + 1);
|
|
217
|
+
}
|
|
218
|
+
this.emitTransitionLedgerNote(rec, null, 'queued', 'createFlow');
|
|
219
|
+
return { flow: rec, created: true };
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Returns count of non-terminal flows for a controller. Lazy-loaded from DB
|
|
224
|
+
* on first call, then maintained incrementally via createFlow / applyOcc.
|
|
225
|
+
*
|
|
226
|
+
* The bookkeeping is best-effort — if it drifts (e.g. server restart),
|
|
227
|
+
* the next call resets from DB. The hot path never goes to DB twice.
|
|
228
|
+
*/
|
|
229
|
+
getActiveCount(controllerId) {
|
|
230
|
+
if (!this.activeCountLoaded.has(controllerId)) {
|
|
231
|
+
let total = 0;
|
|
232
|
+
for (const s of ACTIVE_STATUSES) {
|
|
233
|
+
total += this.store.findByControllerId(controllerId, { status: s }).length;
|
|
234
|
+
}
|
|
235
|
+
this.activeCountByController.set(controllerId, total);
|
|
236
|
+
this.activeCountLoaded.add(controllerId);
|
|
237
|
+
return total;
|
|
238
|
+
}
|
|
239
|
+
return this.activeCountByController.get(controllerId) ?? 0;
|
|
240
|
+
}
|
|
241
|
+
/** Hook: invoked after a flow transitions to a terminal status. */
|
|
242
|
+
onTerminalTransition(rec) {
|
|
243
|
+
if (!this.activeCountLoaded.has(rec.controllerId))
|
|
244
|
+
return;
|
|
245
|
+
const cur = this.activeCountByController.get(rec.controllerId) ?? 0;
|
|
246
|
+
if (cur > 0)
|
|
247
|
+
this.activeCountByController.set(rec.controllerId, cur - 1);
|
|
248
|
+
}
|
|
249
|
+
// ────────────────── transitions ──────────────────
|
|
250
|
+
async startStep(args) {
|
|
251
|
+
if (args.currentStep.length > MAX_CURRENT_STEP_CHARS) {
|
|
252
|
+
throw new TaskFlowError('invalid_argument', 'currentStep exceeds limit', {
|
|
253
|
+
field: 'currentStep',
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return this.applyOcc(args.flowId, args.expectedRevision, (cur) => {
|
|
257
|
+
this.assertControllerScope(cur, args.principal);
|
|
258
|
+
if (cur.status !== 'queued' && cur.status !== 'running') {
|
|
259
|
+
throw new TaskFlowError('invalid_transition', `cannot startStep from ${cur.status}`, {
|
|
260
|
+
from: cur.status,
|
|
261
|
+
op: 'startStep',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const next = {
|
|
265
|
+
...cur,
|
|
266
|
+
status: 'running',
|
|
267
|
+
currentStep: args.currentStep,
|
|
268
|
+
controllerInstanceId: this.controllerInstanceFromPrincipal(args.principal, cur),
|
|
269
|
+
controllerHeartbeatAt: this.now(),
|
|
270
|
+
revision: cur.revision + 1,
|
|
271
|
+
updatedAt: this.now(),
|
|
272
|
+
};
|
|
273
|
+
return next;
|
|
274
|
+
}, 'startStep');
|
|
275
|
+
}
|
|
276
|
+
async setFlowWaiting(args) {
|
|
277
|
+
const parsed = waitJsonSchema.safeParse(args.waitJson);
|
|
278
|
+
if (!parsed.success) {
|
|
279
|
+
throw new TaskFlowError('invalid_argument', 'waitJson failed validation', {
|
|
280
|
+
field: 'waitJson',
|
|
281
|
+
reason: parsed.error.issues[0]?.message,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
if (jsonByteLength(parsed.data) > MAX_WAIT_JSON_BYTES) {
|
|
285
|
+
throw new TaskFlowError('invalid_argument', 'waitJson exceeds size limit', {
|
|
286
|
+
field: 'waitJson',
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return this.applyOcc(args.flowId, args.expectedRevision, (cur) => {
|
|
290
|
+
this.assertControllerScope(cur, args.principal);
|
|
291
|
+
if (cur.status !== 'running') {
|
|
292
|
+
throw new TaskFlowError('invalid_transition', `cannot setFlowWaiting from ${cur.status}`, { from: cur.status, op: 'setFlowWaiting' });
|
|
293
|
+
}
|
|
294
|
+
// Reply uniqueness: at most one active reply wait per (controllerId, channel, threadId, peer).
|
|
295
|
+
if (parsed.data.kind === 'reply') {
|
|
296
|
+
const collisions = this.store.findWaitingReplyByTarget({
|
|
297
|
+
channel: parsed.data.channel,
|
|
298
|
+
threadId: parsed.data.threadId,
|
|
299
|
+
peer: parsed.data.peer,
|
|
300
|
+
controllerId: cur.controllerId,
|
|
301
|
+
});
|
|
302
|
+
if (collisions.length > 0) {
|
|
303
|
+
throw new TaskFlowError('wait_collision', 'duplicate reply wait for (controllerId, channel, threadId, peer)', { existingFlowId: collisions[0].flowId });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const ts = this.now();
|
|
307
|
+
let nextState = cur.stateJson;
|
|
308
|
+
if (args.statePatch !== undefined) {
|
|
309
|
+
nextState = args.statePatch;
|
|
310
|
+
this.validateStateJsonSize(nextState);
|
|
311
|
+
}
|
|
312
|
+
const next = {
|
|
313
|
+
...cur,
|
|
314
|
+
status: 'waiting',
|
|
315
|
+
waitJson: parsed.data,
|
|
316
|
+
waitInstanceId: this.genId(),
|
|
317
|
+
waitStartedAt: ts,
|
|
318
|
+
currentStep: args.currentStep ?? cur.currentStep,
|
|
319
|
+
notifyPolicy: args.notifyPolicy ?? cur.notifyPolicy,
|
|
320
|
+
stateJson: nextState,
|
|
321
|
+
controllerInstanceId: this.controllerInstanceFromPrincipal(args.principal, cur),
|
|
322
|
+
controllerHeartbeatAt: ts,
|
|
323
|
+
revision: cur.revision + 1,
|
|
324
|
+
updatedAt: ts,
|
|
325
|
+
};
|
|
326
|
+
return next;
|
|
327
|
+
}, 'setFlowWaiting');
|
|
328
|
+
}
|
|
329
|
+
async resumeFlow(args) {
|
|
330
|
+
return this.applyOcc(args.flowId, args.expectedRevision, (cur) => {
|
|
331
|
+
// controller principal must match controllerId; system-waker permitted.
|
|
332
|
+
if (args.principal.scope === 'controller') {
|
|
333
|
+
this.assertControllerScope(cur, args.principal);
|
|
334
|
+
}
|
|
335
|
+
else if (args.principal.scope !== 'system-waker' && args.principal.scope !== 'admin') {
|
|
336
|
+
throw new TaskFlowError('unauthorized_controller', 'resumeFlow requires controller / system-waker / admin scope', {});
|
|
337
|
+
}
|
|
338
|
+
if (cur.status !== 'waiting') {
|
|
339
|
+
// If the wait already fired, return already_consumed; if the supplied id was never
|
|
340
|
+
// valid, invalid_argument.
|
|
341
|
+
if (cur.waitInstanceId == null && !isTerminalStatus(cur.status)) {
|
|
342
|
+
throw new TaskFlowError('already_consumed', 'wait already fired', {
|
|
343
|
+
waitInstanceId: args.waitInstanceId,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
throw new TaskFlowError('invalid_transition', `cannot resumeFlow from ${cur.status}`, { from: cur.status, op: 'resumeFlow' });
|
|
347
|
+
}
|
|
348
|
+
if (cur.waitInstanceId !== args.waitInstanceId) {
|
|
349
|
+
throw new TaskFlowError('invalid_argument', 'waitInstanceId mismatch', {
|
|
350
|
+
field: 'waitInstanceId',
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
const ts = this.now();
|
|
354
|
+
let nextState = cur.stateJson;
|
|
355
|
+
if (args.statePatch !== undefined) {
|
|
356
|
+
nextState = args.statePatch;
|
|
357
|
+
this.validateStateJsonSize(nextState);
|
|
358
|
+
}
|
|
359
|
+
const next = {
|
|
360
|
+
...cur,
|
|
361
|
+
status: 'running',
|
|
362
|
+
waitJson: undefined,
|
|
363
|
+
waitInstanceId: undefined,
|
|
364
|
+
waitStartedAt: undefined,
|
|
365
|
+
currentStep: args.currentStep ?? cur.currentStep,
|
|
366
|
+
stateJson: nextState,
|
|
367
|
+
controllerInstanceId: args.principal.scope === 'controller'
|
|
368
|
+
? args.principal.controllerInstanceId
|
|
369
|
+
: cur.controllerInstanceId,
|
|
370
|
+
controllerHeartbeatAt: ts,
|
|
371
|
+
revision: cur.revision + 1,
|
|
372
|
+
updatedAt: ts,
|
|
373
|
+
};
|
|
374
|
+
return next;
|
|
375
|
+
}, 'resumeFlow');
|
|
376
|
+
}
|
|
377
|
+
async finishFlow(args) {
|
|
378
|
+
return this.applyOcc(args.flowId, args.expectedRevision, (cur) => {
|
|
379
|
+
this.assertControllerScope(cur, args.principal);
|
|
380
|
+
if (cur.status !== 'running') {
|
|
381
|
+
throw new TaskFlowError('invalid_transition', `cannot finishFlow from ${cur.status}`, {
|
|
382
|
+
from: cur.status,
|
|
383
|
+
op: 'finishFlow',
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
const ts = this.now();
|
|
387
|
+
let nextState = cur.stateJson ?? {};
|
|
388
|
+
if (args.result !== undefined) {
|
|
389
|
+
if (typeof nextState !== 'object' || nextState === null)
|
|
390
|
+
nextState = {};
|
|
391
|
+
nextState = { ...nextState, _result: args.result };
|
|
392
|
+
this.validateStateJsonSize(nextState);
|
|
393
|
+
}
|
|
394
|
+
const next = {
|
|
395
|
+
...cur,
|
|
396
|
+
status: 'succeeded',
|
|
397
|
+
stateJson: nextState,
|
|
398
|
+
revision: cur.revision + 1,
|
|
399
|
+
updatedAt: ts,
|
|
400
|
+
endedAt: ts,
|
|
401
|
+
};
|
|
402
|
+
return next;
|
|
403
|
+
}, 'finishFlow');
|
|
404
|
+
}
|
|
405
|
+
async failFlow(args) {
|
|
406
|
+
return this.applyOcc(args.flowId, args.expectedRevision, (cur) => {
|
|
407
|
+
this.assertControllerScope(cur, args.principal);
|
|
408
|
+
if (cur.status !== 'running' && cur.status !== 'waiting') {
|
|
409
|
+
throw new TaskFlowError('invalid_transition', `cannot failFlow from ${cur.status}`, {
|
|
410
|
+
from: cur.status,
|
|
411
|
+
op: 'failFlow',
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
const ts = this.now();
|
|
415
|
+
let nextState = cur.stateJson ?? {};
|
|
416
|
+
if (typeof nextState !== 'object' || nextState === null)
|
|
417
|
+
nextState = {};
|
|
418
|
+
nextState = { ...nextState, _failureReason: args.failureReason };
|
|
419
|
+
this.validateStateJsonSize(nextState);
|
|
420
|
+
const next = {
|
|
421
|
+
...cur,
|
|
422
|
+
status: 'failed',
|
|
423
|
+
stateJson: nextState,
|
|
424
|
+
// clear wait state since transitioning from waiting → failed
|
|
425
|
+
waitJson: undefined,
|
|
426
|
+
waitInstanceId: undefined,
|
|
427
|
+
waitStartedAt: undefined,
|
|
428
|
+
revision: cur.revision + 1,
|
|
429
|
+
updatedAt: ts,
|
|
430
|
+
endedAt: ts,
|
|
431
|
+
};
|
|
432
|
+
return next;
|
|
433
|
+
}, 'failFlow');
|
|
434
|
+
}
|
|
435
|
+
async requestFlowCancel(args) {
|
|
436
|
+
return this.applyOcc(args.flowId, args.expectedRevision, (cur) => {
|
|
437
|
+
if (isTerminalStatus(cur.status)) {
|
|
438
|
+
throw new TaskFlowError('already_terminal', 'cannot cancel a terminal flow', {});
|
|
439
|
+
}
|
|
440
|
+
const ts = this.now();
|
|
441
|
+
const next = {
|
|
442
|
+
...cur,
|
|
443
|
+
cancelRequestedAt: ts,
|
|
444
|
+
cancelRequestedBy: args.requesterOrigin,
|
|
445
|
+
revision: cur.revision + 1,
|
|
446
|
+
updatedAt: ts,
|
|
447
|
+
};
|
|
448
|
+
return next;
|
|
449
|
+
}, 'requestFlowCancel', (next, prev) => {
|
|
450
|
+
if (next.status === 'waiting') {
|
|
451
|
+
this.emit('taskflow:cancel-requested', {
|
|
452
|
+
flowId: next.flowId,
|
|
453
|
+
controllerId: next.controllerId,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
// Audit ledger note for cancel-request
|
|
457
|
+
this.emitLedgerNote('taskflow-cancel-requested', {
|
|
458
|
+
flowId: next.flowId,
|
|
459
|
+
revision: next.revision,
|
|
460
|
+
cancelRequestedBy: args.requesterOrigin,
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
async cancelFlow(args) {
|
|
465
|
+
return this.applyOcc(args.flowId, args.expectedRevision, (cur) => {
|
|
466
|
+
this.assertControllerScope(cur, args.principal);
|
|
467
|
+
if (isTerminalStatus(cur.status)) {
|
|
468
|
+
throw new TaskFlowError('already_terminal', 'flow is already terminal', {});
|
|
469
|
+
}
|
|
470
|
+
if (cur.cancelRequestedAt == null) {
|
|
471
|
+
throw new TaskFlowError('invalid_transition', 'cancelFlow requires a prior requestFlowCancel', { from: cur.status, op: 'cancelFlow' });
|
|
472
|
+
}
|
|
473
|
+
const ts = this.now();
|
|
474
|
+
const next = {
|
|
475
|
+
...cur,
|
|
476
|
+
status: 'cancelled',
|
|
477
|
+
waitJson: undefined,
|
|
478
|
+
waitInstanceId: undefined,
|
|
479
|
+
waitStartedAt: undefined,
|
|
480
|
+
revision: cur.revision + 1,
|
|
481
|
+
updatedAt: ts,
|
|
482
|
+
endedAt: ts,
|
|
483
|
+
};
|
|
484
|
+
return next;
|
|
485
|
+
}, 'cancelFlow');
|
|
486
|
+
}
|
|
487
|
+
async markLost(args) {
|
|
488
|
+
return this.applyOcc(args.flowId, args.expectedRevision, (cur) => {
|
|
489
|
+
if (cur.status !== 'running' && cur.status !== 'waiting') {
|
|
490
|
+
throw new TaskFlowError('invalid_transition', `cannot markLost from ${cur.status}`, {
|
|
491
|
+
from: cur.status,
|
|
492
|
+
op: 'markLost',
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
const ts = this.now();
|
|
496
|
+
const next = {
|
|
497
|
+
...cur,
|
|
498
|
+
status: 'lost',
|
|
499
|
+
waitJson: undefined,
|
|
500
|
+
waitInstanceId: undefined,
|
|
501
|
+
waitStartedAt: undefined,
|
|
502
|
+
supersededBy: { kind: 'ledger-note', ledgerEntryId: args.ledgerEntryId, reason: args.reason },
|
|
503
|
+
revision: cur.revision + 1,
|
|
504
|
+
updatedAt: ts,
|
|
505
|
+
endedAt: ts,
|
|
506
|
+
};
|
|
507
|
+
return next;
|
|
508
|
+
}, 'markLost');
|
|
509
|
+
}
|
|
510
|
+
async pingFlow(args) {
|
|
511
|
+
if (args.principal.scope !== 'controller') {
|
|
512
|
+
throw new TaskFlowError('unauthorized_controller', 'pingFlow requires controller scope', {});
|
|
513
|
+
}
|
|
514
|
+
const cur = this.store.getFlow(args.flowId);
|
|
515
|
+
if (!cur)
|
|
516
|
+
throw new TaskFlowError('not_found', 'flow not found', { flowId: args.flowId });
|
|
517
|
+
if (cur.controllerId !== args.principal.controllerId) {
|
|
518
|
+
throw new TaskFlowError('unauthorized_controller', 'controllerId mismatch', {
|
|
519
|
+
expected: cur.controllerId,
|
|
520
|
+
actual: args.principal.controllerId,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
if (cur.status !== 'running') {
|
|
524
|
+
throw new TaskFlowError('invalid_transition', `cannot pingFlow when status=${cur.status}`, {
|
|
525
|
+
from: cur.status,
|
|
526
|
+
op: 'pingFlow',
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
// ── Phase 5 ping rate limit ──────────────────────────────────────
|
|
530
|
+
// Spec § Threat Model line 685 — heartbeat-flood mitigation. Authority
|
|
531
|
+
// check first (above): bogus pings don't even count against the limit.
|
|
532
|
+
const rateRes = this.pingLimiter.tryAcquire(args.flowId);
|
|
533
|
+
if (!rateRes.ok) {
|
|
534
|
+
this.rateLimitRejections.ping_rate++;
|
|
535
|
+
throw new TaskFlowError('quota_exceeded', `pingFlow rate limit exceeded for flowId=${args.flowId}`, {
|
|
536
|
+
code: 'rate_limited',
|
|
537
|
+
scope: 'ping',
|
|
538
|
+
limit: this.rateLimits.pingPerMinPerFlow,
|
|
539
|
+
windowMs: 60_000,
|
|
540
|
+
retryAfterMs: rateRes.retryAfterMs ?? 60_000,
|
|
541
|
+
flowId: args.flowId,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
const ts = this.now();
|
|
545
|
+
const updated = this.store.pingFlowRow({
|
|
546
|
+
flowId: args.flowId,
|
|
547
|
+
controllerInstanceId: args.principal.controllerInstanceId,
|
|
548
|
+
controllerHeartbeatAt: ts,
|
|
549
|
+
updatedAt: ts,
|
|
550
|
+
});
|
|
551
|
+
if (updated === 0) {
|
|
552
|
+
// Race: the row's status changed between read and write. Return current.
|
|
553
|
+
const fresh = this.store.getFlow(args.flowId);
|
|
554
|
+
if (fresh)
|
|
555
|
+
this.cachePut(fresh);
|
|
556
|
+
throw new TaskFlowError('invalid_transition', 'pingFlow lost a race', {
|
|
557
|
+
from: fresh?.status ?? 'unknown',
|
|
558
|
+
op: 'pingFlow',
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
const next = {
|
|
562
|
+
...cur,
|
|
563
|
+
controllerInstanceId: args.principal.controllerInstanceId,
|
|
564
|
+
controllerHeartbeatAt: ts,
|
|
565
|
+
updatedAt: ts,
|
|
566
|
+
};
|
|
567
|
+
this.cachePut(next);
|
|
568
|
+
return next;
|
|
569
|
+
}
|
|
570
|
+
// ────────────────── internal: OCC apply ──────────────────
|
|
571
|
+
async applyOcc(flowId, expectedRevision, mutate, op, afterCommit) {
|
|
572
|
+
const result = await this.store.withWriteTransaction(() => {
|
|
573
|
+
const cur = this.store.getFlow(flowId);
|
|
574
|
+
if (!cur)
|
|
575
|
+
throw new TaskFlowError('not_found', 'flow not found', { flowId });
|
|
576
|
+
if (isTerminalStatus(cur.status)) {
|
|
577
|
+
throw new TaskFlowError('already_terminal', 'flow already terminal', {
|
|
578
|
+
current: cur,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
if (cur.revision !== expectedRevision) {
|
|
582
|
+
throw new TaskFlowError('revision_conflict', `expected revision ${expectedRevision}, current ${cur.revision}`, { current: cur });
|
|
583
|
+
}
|
|
584
|
+
const next = mutate(cur);
|
|
585
|
+
const changes = this.store.patchFlowOcc(next, expectedRevision);
|
|
586
|
+
if (changes !== 1) {
|
|
587
|
+
// Highly unlikely inside BEGIN IMMEDIATE — re-read for the discriminator.
|
|
588
|
+
const meta = this.store.getFlowMeta(flowId);
|
|
589
|
+
if (!meta)
|
|
590
|
+
throw new TaskFlowError('not_found', 'flow not found', { flowId });
|
|
591
|
+
throw new TaskFlowError('revision_conflict', 'patch returned 0 rows mid-transaction', { current: this.store.getFlow(flowId) });
|
|
592
|
+
}
|
|
593
|
+
return { prev: cur, next };
|
|
594
|
+
});
|
|
595
|
+
this.cachePut(result.next);
|
|
596
|
+
// Active-count bookkeeping: decrement on terminal entry.
|
|
597
|
+
if (!isTerminalStatus(result.prev.status) && isTerminalStatus(result.next.status)) {
|
|
598
|
+
this.onTerminalTransition(result.next);
|
|
599
|
+
// Heartbeat bucket is also pointless for terminal flows — let it GC.
|
|
600
|
+
this.pingLimiter.forget(result.next.flowId);
|
|
601
|
+
}
|
|
602
|
+
this.emitTransitionLedgerNote(result.next, result.prev.status, result.next.status, op);
|
|
603
|
+
if (afterCommit) {
|
|
604
|
+
try {
|
|
605
|
+
afterCommit(result.next, result.prev);
|
|
606
|
+
}
|
|
607
|
+
catch (err) {
|
|
608
|
+
// afterCommit failures are best-effort — never roll back state.
|
|
609
|
+
// Fall through.
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
this.maybeNotify(result.next, result.prev.status);
|
|
613
|
+
return { applied: true, flow: result.next };
|
|
614
|
+
}
|
|
615
|
+
cachePut(rec) {
|
|
616
|
+
this.cache.set(rec.flowId, rec);
|
|
617
|
+
}
|
|
618
|
+
emitCacheEvictionMetric() {
|
|
619
|
+
// Lightweight metric surface — instar emits `[metric] name=value labels`
|
|
620
|
+
// tagged lines that the dashboard scrapes. Keep emission identical
|
|
621
|
+
// shape to DivergenceChecker.emitMetric for consistency.
|
|
622
|
+
try {
|
|
623
|
+
console.log(`[metric] taskflow_cache_evictions_total=${this.cacheEvictionsTotal} ${JSON.stringify({ scope: 'TaskFlowRegistry' })}`);
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
/* swallow */
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
assertControllerScope(rec, principal) {
|
|
630
|
+
if (principal.scope === 'admin')
|
|
631
|
+
return;
|
|
632
|
+
if (principal.scope !== 'controller') {
|
|
633
|
+
throw new TaskFlowError('unauthorized_controller', 'controller scope required', {});
|
|
634
|
+
}
|
|
635
|
+
if (rec.controllerId !== principal.controllerId) {
|
|
636
|
+
throw new TaskFlowError('unauthorized_controller', 'controllerId mismatch', {
|
|
637
|
+
expected: rec.controllerId,
|
|
638
|
+
actual: principal.controllerId,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
controllerInstanceFromPrincipal(principal, cur) {
|
|
643
|
+
if (principal.scope === 'controller')
|
|
644
|
+
return principal.controllerInstanceId;
|
|
645
|
+
return cur.controllerInstanceId;
|
|
646
|
+
}
|
|
647
|
+
validateStateJsonSize(value) {
|
|
648
|
+
const bytes = jsonByteLength(value);
|
|
649
|
+
if (bytes > MAX_STATE_JSON_BYTES) {
|
|
650
|
+
throw new TaskFlowError('invalid_argument', 'stateJson exceeds size limit', {
|
|
651
|
+
field: 'stateJson',
|
|
652
|
+
bytes,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
toWaitMatch(rec) {
|
|
657
|
+
return {
|
|
658
|
+
flowId: rec.flowId,
|
|
659
|
+
revision: rec.revision,
|
|
660
|
+
waitInstanceId: rec.waitInstanceId,
|
|
661
|
+
controllerId: rec.controllerId,
|
|
662
|
+
waitJson: rec.waitJson,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
genId() {
|
|
666
|
+
return crypto.randomUUID();
|
|
667
|
+
}
|
|
668
|
+
// ────────────────── audit + notify ──────────────────
|
|
669
|
+
/**
|
|
670
|
+
* Emit a redacted state-transition audit note. Per spec § Threat Model
|
|
671
|
+
* lines 681-682, the audit shape carries only structural metadata:
|
|
672
|
+
* { flowId, revision, currentStep, from_status, to_status, waitJson.kind, controllerId, op }
|
|
673
|
+
*
|
|
674
|
+
* It MUST NOT include `stateJson` (controller-private; spec line 705) and
|
|
675
|
+
* MUST NOT include any field of `waitJson` other than `kind` (spec line 681).
|
|
676
|
+
*
|
|
677
|
+
* Audit emission is fire-and-forget; failures never affect state-machine
|
|
678
|
+
* correctness (tier-2 audit per spec § Storage and Concurrency).
|
|
679
|
+
*/
|
|
680
|
+
emitTransitionLedgerNote(rec, fromStatus, toStatus, op) {
|
|
681
|
+
if (!this.crossesAuditBoundary(fromStatus, toStatus, op))
|
|
682
|
+
return;
|
|
683
|
+
if (!this.ledger)
|
|
684
|
+
return;
|
|
685
|
+
// Redacted shape per spec lines 681-682.
|
|
686
|
+
const auditPayload = {
|
|
687
|
+
flowId: rec.flowId,
|
|
688
|
+
revision: rec.revision,
|
|
689
|
+
currentStep: rec.currentStep ?? null,
|
|
690
|
+
from_status: fromStatus,
|
|
691
|
+
to_status: toStatus,
|
|
692
|
+
waitKind: rec.waitJson?.kind ?? null,
|
|
693
|
+
controllerId: rec.controllerId,
|
|
694
|
+
op,
|
|
695
|
+
};
|
|
696
|
+
// Summary embeds only the redacted fields; subject is the static kind tag.
|
|
697
|
+
// This keeps the SharedStateLedger schema (subject required, max 200 chars,
|
|
698
|
+
// summary max 400) happy without leaking stateJson.
|
|
699
|
+
const summary = `flow=${rec.flowId} rev=${rec.revision} ` +
|
|
700
|
+
`${fromStatus ?? 'null'}->${toStatus} ` +
|
|
701
|
+
`step=${rec.currentStep ?? 'none'} ` +
|
|
702
|
+
`wait=${rec.waitJson?.kind ?? 'none'} ` +
|
|
703
|
+
`op=${op}`;
|
|
704
|
+
void Promise.resolve()
|
|
705
|
+
.then(() => this.ledger.append({
|
|
706
|
+
kind: 'note',
|
|
707
|
+
provenance: 'subsystem-asserted',
|
|
708
|
+
emittedBy: {
|
|
709
|
+
subsystem: 'taskflow-transition',
|
|
710
|
+
instance: 'TaskFlowRegistry',
|
|
711
|
+
},
|
|
712
|
+
counterparty: { type: 'self', name: rec.controllerId.slice(0, 64).replace(/[^a-zA-Z0-9\-_.:]/g, '_'), trustTier: 'trusted' },
|
|
713
|
+
subject: 'taskflow-transition',
|
|
714
|
+
summary: summary.length > 400 ? summary.slice(0, 400) : summary,
|
|
715
|
+
dedupKey: `taskflow-transition:${rec.flowId}:${rec.revision}:${op}`,
|
|
716
|
+
}))
|
|
717
|
+
.catch(() => {
|
|
718
|
+
/* swallow — audit best-effort */
|
|
719
|
+
});
|
|
720
|
+
// Expose the redacted payload via an event for tests / dashboards.
|
|
721
|
+
this.emit('taskflow:audit-emitted', auditPayload);
|
|
722
|
+
}
|
|
723
|
+
crossesAuditBoundary(fromStatus, toStatus, op) {
|
|
724
|
+
if (op === 'createFlow')
|
|
725
|
+
return true;
|
|
726
|
+
if (op === 'startStep')
|
|
727
|
+
return true;
|
|
728
|
+
if (op === 'setFlowWaiting')
|
|
729
|
+
return true;
|
|
730
|
+
if (op === 'resumeFlow')
|
|
731
|
+
return true;
|
|
732
|
+
if (isTerminalStatus(toStatus))
|
|
733
|
+
return true;
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Emit a generic redacted ledger note (used by cancel-requested path).
|
|
738
|
+
* Same redaction discipline as emitTransitionLedgerNote.
|
|
739
|
+
*/
|
|
740
|
+
emitLedgerNote(kind, payload) {
|
|
741
|
+
if (!this.ledger)
|
|
742
|
+
return;
|
|
743
|
+
void Promise.resolve()
|
|
744
|
+
.then(() => this.ledger.append({
|
|
745
|
+
kind: 'note',
|
|
746
|
+
provenance: 'subsystem-asserted',
|
|
747
|
+
emittedBy: {
|
|
748
|
+
subsystem: 'taskflow-transition',
|
|
749
|
+
instance: 'TaskFlowRegistry',
|
|
750
|
+
},
|
|
751
|
+
counterparty: { type: 'self', name: 'TaskFlowRegistry', trustTier: 'trusted' },
|
|
752
|
+
subject: kind.slice(0, 200),
|
|
753
|
+
summary: `flow=${payload.flowId} rev=${payload.revision ?? 'na'} kind=${kind}`,
|
|
754
|
+
dedupKey: `${kind}:${payload.flowId}:${payload.revision ?? 'na'}`,
|
|
755
|
+
}))
|
|
756
|
+
.catch(() => {
|
|
757
|
+
/* swallow */
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
maybeNotify(rec, fromStatus) {
|
|
761
|
+
const policy = rec.notifyPolicy;
|
|
762
|
+
if (policy.kind === 'silent')
|
|
763
|
+
return;
|
|
764
|
+
const enteredWaiting = fromStatus !== 'waiting' && rec.status === 'waiting';
|
|
765
|
+
const enteredTerminal = !isTerminalStatus(fromStatus) && isTerminalStatus(rec.status);
|
|
766
|
+
let fire = false;
|
|
767
|
+
if (policy.kind === 'on-wait' && enteredWaiting)
|
|
768
|
+
fire = true;
|
|
769
|
+
if (policy.kind === 'on-terminal' && enteredTerminal)
|
|
770
|
+
fire = true;
|
|
771
|
+
if (policy.kind === 'on-wait-and-terminal' && (enteredWaiting || enteredTerminal))
|
|
772
|
+
fire = true;
|
|
773
|
+
if (!fire)
|
|
774
|
+
return;
|
|
775
|
+
// Phase 1 ships the wiring with metric-only emission. Phase 5 wires through
|
|
776
|
+
// TelegramAdapter.send.
|
|
777
|
+
this.notifyMetrics.emitted++;
|
|
778
|
+
const k = enteredTerminal ? `terminal:${rec.status}` : 'wait';
|
|
779
|
+
this.notifyMetrics.byKind[k] = (this.notifyMetrics.byKind[k] ?? 0) + 1;
|
|
780
|
+
this.emit('taskflow:notify', {
|
|
781
|
+
flowId: rec.flowId,
|
|
782
|
+
goal: rec.goal,
|
|
783
|
+
currentStep: rec.currentStep,
|
|
784
|
+
status: rec.status,
|
|
785
|
+
waitKind: rec.waitJson?.kind,
|
|
786
|
+
revision: rec.revision,
|
|
787
|
+
topicId: 'topicId' in policy ? policy.topicId : undefined,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
//# sourceMappingURL=TaskFlowRegistry.js.map
|