@bluewombat/work-ledger 0.1.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.
@@ -0,0 +1,641 @@
1
+ import { checkPlan, initialSubtaskState, refreshRunnable } from "./check-plan.js";
2
+ const DEFAULT_BAIL_DURATION_MS = 3_600_000;
3
+ const ACTIVE_STATES = new Set([
4
+ "planning",
5
+ "running",
6
+ "escalated",
7
+ "integrating",
8
+ "submitted",
9
+ "merging",
10
+ ]);
11
+ const IN_FLIGHT = new Set([
12
+ "planning",
13
+ "running",
14
+ "integrating",
15
+ "submitted",
16
+ ]);
17
+ function refused(code) {
18
+ return { ok: false, code };
19
+ }
20
+ function cloneIntention(feature) {
21
+ const intention = {
22
+ key: feature.key,
23
+ project: feature.project,
24
+ fingerprint: feature.fingerprint,
25
+ priority: feature.priority,
26
+ intention: feature.intention,
27
+ };
28
+ if (feature.title !== undefined) {
29
+ intention.title = feature.title;
30
+ }
31
+ if (feature.manager !== undefined) {
32
+ intention.manager = feature.manager;
33
+ }
34
+ if (feature.external_id !== undefined) {
35
+ intention.external_id = feature.external_id;
36
+ }
37
+ if (feature.source !== undefined) {
38
+ intention.source = { ...feature.source };
39
+ }
40
+ return intention;
41
+ }
42
+ function pickReceived(summaries, project) {
43
+ const candidates = summaries.filter((s) => s.project === project && s.state === "received");
44
+ candidates.sort((a, b) => {
45
+ if (b.priority !== a.priority) {
46
+ return b.priority - a.priority;
47
+ }
48
+ return a.received_at - b.received_at;
49
+ });
50
+ return candidates[0];
51
+ }
52
+ function projectIsBusy(summaries, project) {
53
+ return summaries.some((s) => s.project === project && ACTIVE_STATES.has(s.state));
54
+ }
55
+ function unblocksCount(subtaskId, subtasks) {
56
+ return subtasks.filter((st) => st.depends_on.includes(subtaskId)).length;
57
+ }
58
+ export function openWorkLedger(options) {
59
+ const persist = options.persist;
60
+ const now = options.now ?? (() => Date.now());
61
+ const bailDurationMs = options.bailDurationMs ?? DEFAULT_BAIL_DURATION_MS;
62
+ const write = async (aggregate) => {
63
+ const saved = await persist.save(aggregate.intention.key, aggregate);
64
+ if (!saved.ok) {
65
+ return refused("persist-failed");
66
+ }
67
+ return { ok: true };
68
+ };
69
+ const loadOrRefuse = async (key) => {
70
+ const found = await persist.load(key);
71
+ if (found === undefined) {
72
+ return refused("not-found");
73
+ }
74
+ return { ok: true, aggregate: structuredClone(found) };
75
+ };
76
+ return {
77
+ async admit(feature) {
78
+ const existing = await persist.load(feature.key);
79
+ if (existing === undefined) {
80
+ const aggregate = {
81
+ intention: cloneIntention(feature),
82
+ state: "received",
83
+ received_at: now(),
84
+ attempts: [],
85
+ attempts_used: 0,
86
+ };
87
+ return await write(aggregate);
88
+ }
89
+ if (existing.state === "merging" || existing.state === "done") {
90
+ return refused("too-late");
91
+ }
92
+ if (existing.state === "cancelled") {
93
+ return refused("illegal-transition");
94
+ }
95
+ if (IN_FLIGHT.has(existing.state)) {
96
+ if (feature.fingerprint === existing.intention.fingerprint ||
97
+ feature.fingerprint === existing.pending_fingerprint) {
98
+ // Same body as the live intention, or as an edit already recorded:
99
+ // an echo of a report, not a newer FeatureStandard.
100
+ return { ok: true };
101
+ }
102
+ const next = structuredClone(existing);
103
+ next.pending_fingerprint = feature.fingerprint;
104
+ return await write(next);
105
+ }
106
+ if (existing.state === "received" && existing.intention.fingerprint === feature.fingerprint) {
107
+ return { ok: true };
108
+ }
109
+ if (existing.state === "received" ||
110
+ existing.state === "invalid" ||
111
+ existing.state === "escalated") {
112
+ const next = structuredClone(existing);
113
+ next.intention = cloneIntention(feature);
114
+ next.state = "received";
115
+ delete next.invalid;
116
+ delete next.pending_fingerprint;
117
+ return await write(next);
118
+ }
119
+ return refused("illegal-transition");
120
+ },
121
+ async recordInvalid(input) {
122
+ const existing = await persist.load(input.key);
123
+ if (existing !== undefined && (existing.state === "merging" || existing.state === "done")) {
124
+ return refused("too-late");
125
+ }
126
+ if (existing === undefined) {
127
+ const aggregate = {
128
+ intention: {
129
+ key: input.key,
130
+ project: input.project,
131
+ fingerprint: input.fingerprint ?? "",
132
+ priority: input.priority ?? 0,
133
+ intention: "",
134
+ },
135
+ state: "invalid",
136
+ received_at: now(),
137
+ attempts: [],
138
+ attempts_used: 0,
139
+ invalid: { code: input.code, reason: input.reason },
140
+ };
141
+ return await write(aggregate);
142
+ }
143
+ const next = structuredClone(existing);
144
+ next.state = "invalid";
145
+ next.invalid = { code: input.code, reason: input.reason };
146
+ if (input.fingerprint !== undefined) {
147
+ next.intention.fingerprint = input.fingerprint;
148
+ }
149
+ if (input.priority !== undefined) {
150
+ next.intention.priority = input.priority;
151
+ }
152
+ next.intention.project = input.project;
153
+ delete next.bail;
154
+ return await write(next);
155
+ },
156
+ async claim(project) {
157
+ const summaries = await persist.listSummaries();
158
+ if (projectIsBusy(summaries, project)) {
159
+ return refused("project-busy");
160
+ }
161
+ const picked = pickReceived(summaries, project);
162
+ if (picked === undefined) {
163
+ return { ok: true, empty: true };
164
+ }
165
+ const loaded = await persist.load(picked.key);
166
+ if (loaded === undefined || loaded.state !== "received") {
167
+ return { ok: true, empty: true };
168
+ }
169
+ const next = structuredClone(loaded);
170
+ next.state = "planning";
171
+ next.bail = { expires_at: now() + bailDurationMs };
172
+ const saved = await write(next);
173
+ if (!saved.ok) {
174
+ return saved;
175
+ }
176
+ return { ok: true, key: picked.key };
177
+ },
178
+ async recordPlan(input) {
179
+ const current = await loadOrRefuse(input.key);
180
+ if (!current.ok) {
181
+ return current;
182
+ }
183
+ const { aggregate } = current;
184
+ if (aggregate.state !== "planning") {
185
+ return refused("illegal-transition");
186
+ }
187
+ if (aggregate.plan !== undefined) {
188
+ return refused("plan-frozen");
189
+ }
190
+ const checked = checkPlan(input.subtasks);
191
+ if (!checked.ok) {
192
+ return refused("illegal-transition");
193
+ }
194
+ aggregate.plan = {
195
+ planned_at: input.plannedAt,
196
+ subtasks: checked.subtasks.map((draft) => ({
197
+ ...draft,
198
+ state: initialSubtaskState(draft),
199
+ })),
200
+ };
201
+ aggregate.state = "running";
202
+ aggregate.bail = { expires_at: now() + bailDurationMs };
203
+ return await write(aggregate);
204
+ },
205
+ async refusePlan(input) {
206
+ const current = await loadOrRefuse(input.key);
207
+ if (!current.ok) {
208
+ return current;
209
+ }
210
+ const { aggregate } = current;
211
+ if (aggregate.state !== "planning") {
212
+ return refused("illegal-transition");
213
+ }
214
+ aggregate.state = "escalated";
215
+ aggregate.escalation = { kind: "plan", born_in_merging: false };
216
+ aggregate.invalid = { code: input.code, reason: input.reason };
217
+ delete aggregate.bail;
218
+ return await write(aggregate);
219
+ },
220
+ async startSubtask(input) {
221
+ const current = await loadOrRefuse(input.key);
222
+ if (!current.ok) {
223
+ return current;
224
+ }
225
+ const { aggregate } = current;
226
+ if (aggregate.plan === undefined) {
227
+ return refused("plan-not-frozen");
228
+ }
229
+ if (aggregate.state !== "running") {
230
+ return refused("illegal-transition");
231
+ }
232
+ if (aggregate.plan.subtasks.some((st) => st.state === "running")) {
233
+ return refused("another-running");
234
+ }
235
+ const subtask = aggregate.plan.subtasks.find((st) => st.id === input.subtaskId);
236
+ if (subtask === undefined) {
237
+ return refused("not-found");
238
+ }
239
+ if (subtask.state !== "runnable") {
240
+ return refused("subtask-not-runnable");
241
+ }
242
+ subtask.state = "running";
243
+ aggregate.bail = { expires_at: now() + bailDurationMs };
244
+ return await write(aggregate);
245
+ },
246
+ async recordAttempt(input) {
247
+ const current = await loadOrRefuse(input.key);
248
+ if (!current.ok) {
249
+ return current;
250
+ }
251
+ const { aggregate } = current;
252
+ if (aggregate.plan === undefined) {
253
+ return refused("plan-not-frozen");
254
+ }
255
+ const subtask = aggregate.plan.subtasks.find((st) => st.id === input.subtaskId);
256
+ if (subtask === undefined || subtask.state !== "running") {
257
+ return refused("illegal-transition");
258
+ }
259
+ const existing = aggregate.attempts.filter((a) => a.subtask_id === input.subtaskId);
260
+ const nextNumber = existing.length + 1;
261
+ if (input.number !== nextNumber) {
262
+ return refused("illegal-transition");
263
+ }
264
+ const attempt = {
265
+ subtask_id: input.subtaskId,
266
+ number: input.number,
267
+ round: aggregate.resumes ?? 0,
268
+ };
269
+ if (input.trace !== undefined) {
270
+ attempt.trace = input.trace;
271
+ }
272
+ aggregate.attempts.push(attempt);
273
+ aggregate.attempts_used += 1;
274
+ return await write(aggregate);
275
+ },
276
+ async markSubtaskIntegrated(input) {
277
+ const current = await loadOrRefuse(input.key);
278
+ if (!current.ok) {
279
+ return current;
280
+ }
281
+ const { aggregate } = current;
282
+ if (aggregate.state !== "running" || aggregate.plan === undefined) {
283
+ return refused("illegal-transition");
284
+ }
285
+ const subtask = aggregate.plan.subtasks.find((st) => st.id === input.subtaskId);
286
+ if (subtask === undefined || subtask.state !== "running") {
287
+ return refused("illegal-transition");
288
+ }
289
+ subtask.state = "integrated";
290
+ aggregate.plan.subtasks = refreshRunnable(aggregate.plan.subtasks);
291
+ const terminal = new Set(["integrated", "cancelled"]);
292
+ const remaining = aggregate.plan.subtasks.some((st) => !terminal.has(st.state));
293
+ if (!remaining) {
294
+ aggregate.state = "integrating";
295
+ }
296
+ aggregate.bail = { expires_at: now() + bailDurationMs };
297
+ return await write(aggregate);
298
+ },
299
+ /**
300
+ * The assembled feature is now in front of the Authority.
301
+ *
302
+ * A second call on an already submitted feature keeps the first reference:
303
+ * the work was pushed again to the same place, and the Authority is judging
304
+ * the same Submission.
305
+ */
306
+ async markSubmitted(input) {
307
+ const current = await loadOrRefuse(input.key);
308
+ if (!current.ok) {
309
+ return current;
310
+ }
311
+ const { aggregate } = current;
312
+ if (aggregate.state !== "integrating" && aggregate.state !== "submitted") {
313
+ return refused("illegal-transition");
314
+ }
315
+ aggregate.state = "submitted";
316
+ // Rebuilt field by field, so anything added here has to be carried on
317
+ // purpose. What was already sent back is carried: a Submission offered
318
+ // again is the same Submission, and dropping the reason leaves whoever
319
+ // reads the next escalation with a frozen Feature and no cause.
320
+ const previous = aggregate.submission;
321
+ aggregate.submission = {
322
+ reference: previous?.reference ?? input.reference,
323
+ submitted_at: previous?.submitted_at ?? now(),
324
+ refusals: previous?.refusals ?? 0,
325
+ ...(previous?.last_report === undefined ? {} : { last_report: previous.last_report }),
326
+ ...(previous?.last_refused_by === undefined
327
+ ? {}
328
+ : { last_refused_by: previous.last_refused_by }),
329
+ };
330
+ aggregate.bail = { expires_at: now() + bailDurationMs };
331
+ return await write(aggregate);
332
+ },
333
+ /**
334
+ * The Authority sent the work back. The report is kept for the next Attempt
335
+ * and the feature returns to integrating, which is where the work is made.
336
+ */
337
+ async recordRefusal(input) {
338
+ const current = await loadOrRefuse(input.key);
339
+ if (!current.ok) {
340
+ return current;
341
+ }
342
+ const { aggregate } = current;
343
+ if (aggregate.state !== "submitted" || aggregate.submission === undefined) {
344
+ return refused("illegal-transition");
345
+ }
346
+ aggregate.submission = {
347
+ ...aggregate.submission,
348
+ refusals: aggregate.submission.refusals + 1,
349
+ last_report: input.report,
350
+ ...(input.refusedBy === undefined ? {} : { last_refused_by: input.refusedBy }),
351
+ };
352
+ aggregate.state = "integrating";
353
+ aggregate.bail = { expires_at: now() + bailDurationMs };
354
+ return await write(aggregate);
355
+ },
356
+ async markMerging(key) {
357
+ const current = await loadOrRefuse(key);
358
+ if (!current.ok) {
359
+ return current;
360
+ }
361
+ const { aggregate } = current;
362
+ if (aggregate.state !== "integrating" && aggregate.state !== "submitted") {
363
+ return refused("illegal-transition");
364
+ }
365
+ aggregate.state = "merging";
366
+ aggregate.bail = { expires_at: now() + bailDurationMs };
367
+ return await write(aggregate);
368
+ },
369
+ async markDone(key, input) {
370
+ const current = await loadOrRefuse(key);
371
+ if (!current.ok) {
372
+ return current;
373
+ }
374
+ const { aggregate } = current;
375
+ if (aggregate.state !== "merging") {
376
+ return refused("illegal-transition");
377
+ }
378
+ aggregate.state = "done";
379
+ if (input?.reference !== undefined) {
380
+ aggregate.integration_reference = input.reference;
381
+ }
382
+ delete aggregate.bail;
383
+ if (aggregate.workspaces !== undefined) {
384
+ delete aggregate.workspaces.subtask;
385
+ if (aggregate.workspaces.feature === undefined) {
386
+ delete aggregate.workspaces;
387
+ }
388
+ }
389
+ return await write(aggregate);
390
+ },
391
+ async escalate(input) {
392
+ const current = await loadOrRefuse(input.key);
393
+ if (!current.ok) {
394
+ return current;
395
+ }
396
+ const { aggregate } = current;
397
+ if (aggregate.state === "done" || aggregate.state === "cancelled") {
398
+ return refused("illegal-transition");
399
+ }
400
+ if (input.kind === "subtask" && input.subtaskId !== undefined) {
401
+ const attempts = aggregate.attempts.filter((a) => a.subtask_id === input.subtaskId);
402
+ if (attempts.length > 0) {
403
+ const last = attempts[attempts.length - 1];
404
+ if (last?.trace === undefined) {
405
+ return refused("missing-trace");
406
+ }
407
+ }
408
+ }
409
+ const bornInMerging = aggregate.state === "merging" || input.kind === "merging";
410
+ aggregate.state = "escalated";
411
+ const escalation = {
412
+ kind: input.kind,
413
+ born_in_merging: bornInMerging,
414
+ };
415
+ if (input.subtaskId !== undefined) {
416
+ escalation.subtask_id = input.subtaskId;
417
+ }
418
+ if (input.report !== undefined && input.report.trim().length > 0) {
419
+ escalation.report = input.report;
420
+ }
421
+ aggregate.escalation = escalation;
422
+ // Freezing for a human consumes the in-flight update signal. Resume
423
+ // continues the stored intention; a newer FeatureStandard lands by admit.
424
+ delete aggregate.pending_fingerprint;
425
+ if (aggregate.plan !== undefined && input.subtaskId !== undefined) {
426
+ const subtask = aggregate.plan.subtasks.find((st) => st.id === input.subtaskId);
427
+ if (subtask !== undefined && subtask.state === "running") {
428
+ subtask.state = "escalated";
429
+ }
430
+ }
431
+ delete aggregate.bail;
432
+ return await write(aggregate);
433
+ },
434
+ async resumeReady(key) {
435
+ const current = await loadOrRefuse(key);
436
+ if (!current.ok) {
437
+ return current;
438
+ }
439
+ const { aggregate } = current;
440
+ if (aggregate.state === "invalid") {
441
+ return refused("illegal-transition");
442
+ }
443
+ if (aggregate.state !== "escalated" || aggregate.escalation === undefined) {
444
+ return refused("illegal-transition");
445
+ }
446
+ const { kind, subtask_id } = aggregate.escalation;
447
+ aggregate.resumes = (aggregate.resumes ?? 0) + 1;
448
+ if (kind === "plan") {
449
+ delete aggregate.plan;
450
+ aggregate.state = "planning";
451
+ delete aggregate.invalid;
452
+ delete aggregate.escalation;
453
+ aggregate.bail = { expires_at: now() + bailDurationMs };
454
+ return await write(aggregate);
455
+ }
456
+ if (kind === "merging" || kind === "submitted" || kind === "assembly") {
457
+ aggregate.state = "integrating";
458
+ delete aggregate.escalation;
459
+ aggregate.bail = { expires_at: now() + bailDurationMs };
460
+ return await write(aggregate);
461
+ }
462
+ if (kind === "subtask" && subtask_id !== undefined && aggregate.plan !== undefined) {
463
+ const subtask = aggregate.plan.subtasks.find((st) => st.id === subtask_id);
464
+ if (subtask !== undefined) {
465
+ subtask.state = "runnable";
466
+ }
467
+ aggregate.state = "running";
468
+ delete aggregate.escalation;
469
+ aggregate.bail = { expires_at: now() + bailDurationMs };
470
+ return await write(aggregate);
471
+ }
472
+ return refused("illegal-transition");
473
+ },
474
+ async cancel(key) {
475
+ const current = await loadOrRefuse(key);
476
+ if (!current.ok) {
477
+ return current;
478
+ }
479
+ const { aggregate } = current;
480
+ if (aggregate.state === "merging" || aggregate.state === "done") {
481
+ return refused("point-of-no-return");
482
+ }
483
+ if (aggregate.escalation?.born_in_merging === true) {
484
+ return refused("point-of-no-return");
485
+ }
486
+ if (aggregate.state === "cancelled") {
487
+ return { ok: true };
488
+ }
489
+ aggregate.state = "cancelled";
490
+ if (aggregate.plan !== undefined) {
491
+ aggregate.plan.subtasks = aggregate.plan.subtasks.map((st) => st.state === "integrated" ? st : { ...st, state: "cancelled" });
492
+ }
493
+ delete aggregate.bail;
494
+ return await write(aggregate);
495
+ },
496
+ async declareWorkspace(input) {
497
+ const current = await loadOrRefuse(input.key);
498
+ if (!current.ok) {
499
+ return current;
500
+ }
501
+ const { aggregate } = current;
502
+ if (aggregate.state === "done" || aggregate.state === "cancelled") {
503
+ return refused("illegal-transition");
504
+ }
505
+ const workspaces = { ...aggregate.workspaces };
506
+ if (input.feature !== undefined) {
507
+ workspaces.feature = input.feature;
508
+ }
509
+ if (input.subtask !== undefined) {
510
+ workspaces.subtask = input.subtask;
511
+ }
512
+ aggregate.workspaces = workspaces;
513
+ return await write(aggregate);
514
+ },
515
+ async clearWorkspace(input) {
516
+ const current = await loadOrRefuse(input.key);
517
+ if (!current.ok) {
518
+ return current;
519
+ }
520
+ const { aggregate } = current;
521
+ if (aggregate.workspaces === undefined) {
522
+ return { ok: true };
523
+ }
524
+ if (input.feature === true) {
525
+ delete aggregate.workspaces.feature;
526
+ }
527
+ if (input.subtask === true) {
528
+ delete aggregate.workspaces.subtask;
529
+ }
530
+ if (aggregate.workspaces.feature === undefined &&
531
+ aggregate.workspaces.subtask === undefined) {
532
+ delete aggregate.workspaces;
533
+ }
534
+ return await write(aggregate);
535
+ },
536
+ async expireBail(key) {
537
+ const current = await loadOrRefuse(key);
538
+ if (!current.ok) {
539
+ return current;
540
+ }
541
+ const { aggregate } = current;
542
+ if (aggregate.bail === undefined || now() < aggregate.bail.expires_at) {
543
+ return refused("illegal-transition");
544
+ }
545
+ if (aggregate.state === "planning") {
546
+ aggregate.state = "received";
547
+ delete aggregate.bail;
548
+ return await write(aggregate);
549
+ }
550
+ if (aggregate.state === "running" && aggregate.plan !== undefined) {
551
+ const running = aggregate.plan.subtasks.find((st) => st.state === "running");
552
+ if (running !== undefined) {
553
+ running.state = "runnable";
554
+ aggregate.attempts_used += 1;
555
+ }
556
+ if (aggregate.workspaces !== undefined) {
557
+ delete aggregate.workspaces.subtask;
558
+ if (aggregate.workspaces.feature === undefined) {
559
+ delete aggregate.workspaces;
560
+ }
561
+ }
562
+ delete aggregate.bail;
563
+ return await write(aggregate);
564
+ }
565
+ delete aggregate.bail;
566
+ return await write(aggregate);
567
+ },
568
+ async renewBail(key) {
569
+ const current = await loadOrRefuse(key);
570
+ if (!current.ok) {
571
+ return current;
572
+ }
573
+ const { aggregate } = current;
574
+ if (aggregate.bail === undefined) {
575
+ return refused("illegal-transition");
576
+ }
577
+ aggregate.bail = { expires_at: now() + bailDurationMs };
578
+ return await write(aggregate);
579
+ },
580
+ async get(key) {
581
+ const found = await persist.load(key);
582
+ if (found === undefined) {
583
+ return { ok: false, code: "not-found" };
584
+ }
585
+ return { ok: true, aggregate: found };
586
+ },
587
+ async list() {
588
+ return await persist.listSummaries();
589
+ },
590
+ async listInFlight() {
591
+ const summaries = await persist.listSummaries();
592
+ return summaries.filter((summary) => IN_FLIGHT.has(summary.state));
593
+ },
594
+ async activeOn(project) {
595
+ const summaries = await persist.listSummaries();
596
+ return summaries.find((s) => s.project === project && ACTIVE_STATES.has(s.state));
597
+ },
598
+ async nextReceived(project) {
599
+ const summaries = await persist.listSummaries();
600
+ if (projectIsBusy(summaries, project)) {
601
+ return undefined;
602
+ }
603
+ return pickReceived(summaries, project);
604
+ },
605
+ async nextRunnable(key) {
606
+ const found = await persist.load(key);
607
+ if (found?.plan === undefined) {
608
+ return undefined;
609
+ }
610
+ const runnable = found.plan.subtasks.filter((st) => st.state === "runnable");
611
+ if (runnable.length === 0) {
612
+ return undefined;
613
+ }
614
+ runnable.sort((a, b) => unblocksCount(b.id, found.plan?.subtasks ?? []) -
615
+ unblocksCount(a.id, found.plan?.subtasks ?? []));
616
+ return runnable[0]?.id;
617
+ },
618
+ async listDeclaredWorkspaces() {
619
+ const summaries = await persist.listSummaries();
620
+ const listed = [];
621
+ for (const summary of summaries) {
622
+ const aggregate = await persist.load(summary.key);
623
+ if (aggregate?.workspaces === undefined) {
624
+ continue;
625
+ }
626
+ const row = { key: summary.key };
627
+ if (aggregate.workspaces.feature !== undefined) {
628
+ row.feature = aggregate.workspaces.feature;
629
+ }
630
+ if (aggregate.workspaces.subtask !== undefined) {
631
+ row.subtask = aggregate.workspaces.subtask;
632
+ }
633
+ if (row.feature !== undefined || row.subtask !== undefined) {
634
+ listed.push(row);
635
+ }
636
+ }
637
+ return listed;
638
+ },
639
+ };
640
+ }
641
+ //# sourceMappingURL=open-work-ledger.js.map