@bluewombat/work-ledger 0.7.0 → 0.9.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.
@@ -1,45 +1,52 @@
1
- import { checkPlan, initialSubtaskState, refreshRunnable } from "./check-plan.js";
1
+ import { isFeatureAggregate, isFeatureSummary } from "../records.js";
2
+ import { openFeatureCommands } from "./feature-commands.js";
3
+ import { refused } from "./results.js";
2
4
  const DEFAULT_BAIL_DURATION_MS = 3_600_000;
3
5
  const ACTIVE_STATES = new Set([
4
- "planning",
5
6
  "running",
6
7
  "escalated",
7
8
  "integrating",
8
9
  "submitted",
9
10
  "merging",
10
11
  ]);
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) {
12
+ const IN_FLIGHT = new Set(["running", "integrating", "submitted"]);
13
+ function cloneIntention(ticket) {
21
14
  const intention = {
22
- key: feature.key,
23
- project: feature.project,
24
- fingerprint: feature.fingerprint,
25
- priority: feature.priority,
26
- intention: feature.intention,
15
+ key: ticket.key,
16
+ project: ticket.project,
17
+ fingerprint: ticket.fingerprint,
18
+ priority: ticket.priority,
19
+ intention: ticket.intention,
27
20
  };
28
- if (feature.title !== undefined) {
29
- intention.title = feature.title;
21
+ if (ticket.title !== undefined) {
22
+ intention.title = ticket.title;
23
+ }
24
+ if (ticket.manager !== undefined) {
25
+ intention.manager = ticket.manager;
26
+ }
27
+ if (ticket.external_id !== undefined) {
28
+ intention.external_id = ticket.external_id;
29
+ }
30
+ if (ticket.source !== undefined) {
31
+ intention.source = { ...ticket.source };
30
32
  }
31
- if (feature.manager !== undefined) {
32
- intention.manager = feature.manager;
33
+ if (ticket.feature !== undefined) {
34
+ intention.feature = ticket.feature;
33
35
  }
34
- if (feature.external_id !== undefined) {
35
- intention.external_id = feature.external_id;
36
+ if (ticket.blocked_by !== undefined) {
37
+ intention.blocked_by = [...ticket.blocked_by];
36
38
  }
37
- if (feature.source !== undefined) {
38
- intention.source = { ...feature.source };
39
+ if (ticket.line !== undefined) {
40
+ intention.line = ticket.line;
39
41
  }
40
42
  return intention;
41
43
  }
42
- function pickReceived(summaries, project) {
44
+ /** The Tickets among what the Port lists: a Feature shares the Port, not the queries. */
45
+ function ticketsOf(summaries) {
46
+ return summaries.filter((summary) => !isFeatureSummary(summary));
47
+ }
48
+ /** Received Tickets of a Project in the order they would start: priority, then age. */
49
+ function receivedOf(summaries, project) {
43
50
  const candidates = summaries.filter((s) => s.project === project && s.state === "received");
44
51
  candidates.sort((a, b) => {
45
52
  if (b.priority !== a.priority) {
@@ -47,38 +54,21 @@ function pickReceived(summaries, project) {
47
54
  }
48
55
  return a.received_at - b.received_at;
49
56
  });
50
- return candidates[0];
57
+ return candidates;
51
58
  }
52
59
  function projectIsBusy(summaries, project) {
53
60
  return summaries.some((s) => s.project === project && ACTIVE_STATES.has(s.state));
54
61
  }
55
- function unblocksCount(subtaskId, subtasks) {
56
- return subtasks.filter((st) => st.depends_on.includes(subtaskId)).length;
57
- }
58
- /** Drop a declared Subtask workspace path; keep the feature path when present. */
59
- function clearSubtaskWorkspace(aggregate) {
60
- if (aggregate.workspaces === undefined) {
61
- return;
62
- }
63
- delete aggregate.workspaces.subtask;
64
- if (aggregate.workspaces.feature === undefined) {
65
- delete aggregate.workspaces;
66
- }
67
- }
68
62
  /**
69
- * A held Subtask becomes runnable from zero. Returns whether one was held.
63
+ * A held unit starts over from zero: its directory is forgotten, the Attempt
64
+ * it was in is spent, and the lease is gone. Returns whether one was held.
70
65
  */
71
- function releaseRunningSubtask(aggregate) {
72
- if (aggregate.plan === undefined) {
73
- return false;
74
- }
75
- const running = aggregate.plan.subtasks.find((st) => st.state === "running");
76
- if (running === undefined) {
66
+ function releaseRunningUnit(aggregate) {
67
+ if (aggregate.state !== "running" || aggregate.bail === undefined) {
77
68
  return false;
78
69
  }
79
- running.state = "runnable";
80
70
  aggregate.attempts_used += 1;
81
- clearSubtaskWorkspace(aggregate);
71
+ delete aggregate.workspace;
82
72
  delete aggregate.bail;
83
73
  return true;
84
74
  }
@@ -86,6 +76,30 @@ export function openWorkLedger(options) {
86
76
  const persist = options.persist;
87
77
  const now = options.now ?? (() => Date.now());
88
78
  const bailDurationMs = options.bailDurationMs ?? DEFAULT_BAIL_DURATION_MS;
79
+ const loadTicket = async (key) => {
80
+ const found = await persist.load(key);
81
+ return found === undefined || isFeatureAggregate(found) ? undefined : found;
82
+ };
83
+ /** Whether every Ticket this one waits on is `done`. A blocker nobody knows blocks. */
84
+ const isUnblocked = async (aggregate) => {
85
+ for (const blocker of aggregate.intention.blocked_by ?? []) {
86
+ const found = await loadTicket(blocker);
87
+ if (found?.state !== "done") {
88
+ return false;
89
+ }
90
+ }
91
+ return true;
92
+ };
93
+ /** The first received Ticket of the Project that may start. */
94
+ const pickReceived = async (summaries, project) => {
95
+ for (const candidate of receivedOf(summaries, project)) {
96
+ const loaded = await loadTicket(candidate.key);
97
+ if (loaded !== undefined && loaded.state === "received" && (await isUnblocked(loaded))) {
98
+ return loaded;
99
+ }
100
+ }
101
+ return undefined;
102
+ };
89
103
  const write = async (aggregate) => {
90
104
  const saved = await persist.save(aggregate.intention.key, aggregate);
91
105
  if (!saved.ok) {
@@ -94,22 +108,23 @@ export function openWorkLedger(options) {
94
108
  return { ok: true };
95
109
  };
96
110
  const loadOrRefuse = async (key) => {
97
- const found = await persist.load(key);
111
+ const found = await loadTicket(key);
98
112
  if (found === undefined) {
99
113
  return refused("not-found");
100
114
  }
101
115
  return { ok: true, aggregate: structuredClone(found) };
102
116
  };
103
117
  return {
104
- async admit(feature) {
105
- const existing = await persist.load(feature.key);
118
+ async admit(ticket) {
119
+ const existing = await loadTicket(ticket.key);
106
120
  if (existing === undefined) {
107
121
  const aggregate = {
108
- intention: cloneIntention(feature),
122
+ intention: cloneIntention(ticket),
109
123
  state: "received",
110
124
  received_at: now(),
111
125
  attempts: [],
112
126
  attempts_used: 0,
127
+ resumes: 0,
113
128
  };
114
129
  return await write(aggregate);
115
130
  }
@@ -120,24 +135,24 @@ export function openWorkLedger(options) {
120
135
  return refused("illegal-transition");
121
136
  }
122
137
  if (IN_FLIGHT.has(existing.state)) {
123
- if (feature.fingerprint === existing.intention.fingerprint ||
124
- feature.fingerprint === existing.pending_fingerprint) {
138
+ if (ticket.fingerprint === existing.intention.fingerprint ||
139
+ ticket.fingerprint === existing.pending_fingerprint) {
125
140
  // Same body as the live intention, or as an edit already recorded:
126
- // an echo of a report, not a newer FeatureStandard.
141
+ // an echo of a report, not a newer TicketStandard.
127
142
  return { ok: true };
128
143
  }
129
144
  const next = structuredClone(existing);
130
- next.pending_fingerprint = feature.fingerprint;
145
+ next.pending_fingerprint = ticket.fingerprint;
131
146
  return await write(next);
132
147
  }
133
- if (existing.state === "received" && existing.intention.fingerprint === feature.fingerprint) {
148
+ if (existing.state === "received" && existing.intention.fingerprint === ticket.fingerprint) {
134
149
  return { ok: true };
135
150
  }
136
151
  if (existing.state === "received" ||
137
152
  existing.state === "invalid" ||
138
153
  existing.state === "escalated") {
139
154
  const next = structuredClone(existing);
140
- next.intention = cloneIntention(feature);
155
+ next.intention = cloneIntention(ticket);
141
156
  next.state = "received";
142
157
  delete next.invalid;
143
158
  delete next.pending_fingerprint;
@@ -146,7 +161,7 @@ export function openWorkLedger(options) {
146
161
  return refused("illegal-transition");
147
162
  },
148
163
  async recordInvalid(input) {
149
- const existing = await persist.load(input.key);
164
+ const existing = await loadTicket(input.key);
150
165
  if (existing !== undefined && (existing.state === "merging" || existing.state === "done")) {
151
166
  return refused("too-late");
152
167
  }
@@ -163,6 +178,7 @@ export function openWorkLedger(options) {
163
178
  received_at: now(),
164
179
  attempts: [],
165
180
  attempts_used: 0,
181
+ resumes: 0,
166
182
  invalid: { code: input.code, reason: input.reason },
167
183
  };
168
184
  return await write(aggregate);
@@ -181,152 +197,77 @@ export function openWorkLedger(options) {
181
197
  return await write(next);
182
198
  },
183
199
  async claim(project) {
184
- const summaries = await persist.listSummaries();
200
+ const summaries = ticketsOf(await persist.listSummaries());
185
201
  if (projectIsBusy(summaries, project)) {
186
202
  return refused("project-busy");
187
203
  }
188
- const picked = pickReceived(summaries, project);
204
+ const picked = await pickReceived(summaries, project);
189
205
  if (picked === undefined) {
190
206
  return { ok: true, empty: true };
191
207
  }
192
- const loaded = await persist.load(picked.key);
193
- if (loaded === undefined || loaded.state !== "received") {
194
- return { ok: true, empty: true };
195
- }
196
- const next = structuredClone(loaded);
197
- next.state = "planning";
198
- next.bail = { expires_at: now() + bailDurationMs };
208
+ const next = structuredClone(picked);
209
+ next.state = "running";
199
210
  const saved = await write(next);
200
211
  if (!saved.ok) {
201
212
  return saved;
202
213
  }
203
- return { ok: true, key: picked.key };
214
+ return { ok: true, key: picked.intention.key };
204
215
  },
205
- async recordPlan(input) {
216
+ async recordAttempt(input) {
206
217
  const current = await loadOrRefuse(input.key);
207
218
  if (!current.ok) {
208
219
  return current;
209
220
  }
210
221
  const { aggregate } = current;
211
- if (aggregate.state !== "planning") {
222
+ if (aggregate.state !== "running") {
212
223
  return refused("illegal-transition");
213
224
  }
214
- if (aggregate.plan !== undefined) {
215
- return refused("plan-frozen");
216
- }
217
- const checked = checkPlan(input.subtasks);
218
- if (!checked.ok) {
225
+ if (input.number !== aggregate.attempts.length + 1) {
219
226
  return refused("illegal-transition");
220
227
  }
221
- aggregate.plan = {
222
- planned_at: input.plannedAt,
223
- subtasks: checked.subtasks.map((draft) => ({
224
- ...draft,
225
- state: initialSubtaskState(draft),
226
- })),
228
+ const attempt = {
229
+ number: input.number,
230
+ round: aggregate.resumes,
227
231
  };
228
- aggregate.state = "running";
229
- aggregate.bail = { expires_at: now() + bailDurationMs };
230
- return await write(aggregate);
231
- },
232
- async refusePlan(input) {
233
- const current = await loadOrRefuse(input.key);
234
- if (!current.ok) {
235
- return current;
236
- }
237
- const { aggregate } = current;
238
- if (aggregate.state !== "planning") {
239
- return refused("illegal-transition");
232
+ if (input.trace !== undefined) {
233
+ attempt.trace = input.trace;
240
234
  }
241
- aggregate.state = "escalated";
242
- aggregate.escalation = { kind: "plan", born_in_merging: false };
243
- aggregate.invalid = { code: input.code, reason: input.reason };
244
- delete aggregate.bail;
235
+ aggregate.attempts.push(attempt);
236
+ aggregate.attempts_used += 1;
245
237
  return await write(aggregate);
246
238
  },
247
- async startSubtask(input) {
248
- const current = await loadOrRefuse(input.key);
239
+ async startUnit(key) {
240
+ const current = await loadOrRefuse(key);
249
241
  if (!current.ok) {
250
242
  return current;
251
243
  }
252
244
  const { aggregate } = current;
253
- if (aggregate.plan === undefined) {
254
- return refused("plan-not-frozen");
255
- }
256
245
  if (aggregate.state !== "running") {
257
246
  return refused("illegal-transition");
258
247
  }
259
- if (aggregate.plan.subtasks.some((st) => st.state === "running")) {
248
+ if (aggregate.bail !== undefined) {
260
249
  return refused("another-running");
261
250
  }
262
- const subtask = aggregate.plan.subtasks.find((st) => st.id === input.subtaskId);
263
- if (subtask === undefined) {
264
- return refused("not-found");
265
- }
266
- if (subtask.state !== "runnable") {
267
- return refused("subtask-not-runnable");
268
- }
269
- subtask.state = "running";
270
251
  aggregate.bail = { expires_at: now() + bailDurationMs };
271
252
  return await write(aggregate);
272
253
  },
273
- async recordAttempt(input) {
274
- const current = await loadOrRefuse(input.key);
275
- if (!current.ok) {
276
- return current;
277
- }
278
- const { aggregate } = current;
279
- if (aggregate.plan === undefined) {
280
- return refused("plan-not-frozen");
281
- }
282
- const subtask = aggregate.plan.subtasks.find((st) => st.id === input.subtaskId);
283
- if (subtask === undefined || subtask.state !== "running") {
284
- return refused("illegal-transition");
285
- }
286
- const existing = aggregate.attempts.filter((a) => a.subtask_id === input.subtaskId);
287
- const nextNumber = existing.length + 1;
288
- if (input.number !== nextNumber) {
289
- return refused("illegal-transition");
290
- }
291
- const attempt = {
292
- subtask_id: input.subtaskId,
293
- number: input.number,
294
- round: aggregate.resumes ?? 0,
295
- };
296
- if (input.trace !== undefined) {
297
- attempt.trace = input.trace;
298
- }
299
- aggregate.attempts.push(attempt);
300
- aggregate.attempts_used += 1;
301
- return await write(aggregate);
302
- },
303
- async markSubtaskIntegrated(input) {
304
- const current = await loadOrRefuse(input.key);
254
+ async markIntegrating(key) {
255
+ const current = await loadOrRefuse(key);
305
256
  if (!current.ok) {
306
257
  return current;
307
258
  }
308
259
  const { aggregate } = current;
309
- if (aggregate.state !== "running" || aggregate.plan === undefined) {
310
- return refused("illegal-transition");
311
- }
312
- const subtask = aggregate.plan.subtasks.find((st) => st.id === input.subtaskId);
313
- if (subtask === undefined || subtask.state !== "running") {
260
+ if (aggregate.state !== "running") {
314
261
  return refused("illegal-transition");
315
262
  }
316
- subtask.state = "integrated";
317
- aggregate.plan.subtasks = refreshRunnable(aggregate.plan.subtasks);
318
- const terminal = new Set(["integrated", "cancelled"]);
319
- const remaining = aggregate.plan.subtasks.some((st) => !terminal.has(st.state));
320
- if (!remaining) {
321
- aggregate.state = "integrating";
322
- }
263
+ aggregate.state = "integrating";
323
264
  aggregate.bail = { expires_at: now() + bailDurationMs };
324
265
  return await write(aggregate);
325
266
  },
326
267
  /**
327
- * The assembled feature is now in front of the Authority.
268
+ * The assembled ticket is now in front of the Authority.
328
269
  *
329
- * A second call on an already submitted feature keeps the first reference:
270
+ * A second call on an already submitted ticket keeps the first reference:
330
271
  * the work was pushed again to the same place, and the Authority is judging
331
272
  * the same Submission.
332
273
  */
@@ -343,7 +284,7 @@ export function openWorkLedger(options) {
343
284
  // Rebuilt field by field, so anything added here has to be carried on
344
285
  // purpose. What was already sent back is carried: a Submission offered
345
286
  // again is the same Submission, and dropping the reason leaves whoever
346
- // reads the next escalation with a frozen Feature and no cause.
287
+ // reads the next escalation with a frozen Ticket and no cause.
347
288
  const previous = aggregate.submission;
348
289
  aggregate.submission = {
349
290
  reference: previous?.reference ?? input.reference,
@@ -359,7 +300,7 @@ export function openWorkLedger(options) {
359
300
  },
360
301
  /**
361
302
  * The Authority sent the work back. The report is kept for the next Attempt
362
- * and the feature returns to integrating, which is where the work is made.
303
+ * and the ticket returns to integrating, which is where the work is made.
363
304
  */
364
305
  async recordRefusal(input) {
365
306
  const current = await loadOrRefuse(input.key);
@@ -404,7 +345,7 @@ export function openWorkLedger(options) {
404
345
  aggregate.parked_refusals = (aggregate.parked_refusals ?? 0) + 1;
405
346
  return await write(aggregate);
406
347
  },
407
- /** `assembly.validate` accepted the feature. The counter is the budget spent; it stays. */
348
+ /** `assembly.validate` accepted the ticket. The counter is the budget spent; it stays. */
408
349
  async clearParkedRefusal(key) {
409
350
  const current = await loadOrRefuse(key);
410
351
  if (!current.ok) {
@@ -444,12 +385,6 @@ export function openWorkLedger(options) {
444
385
  aggregate.integration_reference = input.reference;
445
386
  }
446
387
  delete aggregate.bail;
447
- if (aggregate.workspaces !== undefined) {
448
- delete aggregate.workspaces.subtask;
449
- if (aggregate.workspaces.feature === undefined) {
450
- delete aggregate.workspaces;
451
- }
452
- }
453
388
  return await write(aggregate);
454
389
  },
455
390
  async escalate(input) {
@@ -461,13 +396,10 @@ export function openWorkLedger(options) {
461
396
  if (aggregate.state === "done" || aggregate.state === "cancelled") {
462
397
  return refused("illegal-transition");
463
398
  }
464
- if (input.kind === "subtask" && input.subtaskId !== undefined) {
465
- const attempts = aggregate.attempts.filter((a) => a.subtask_id === input.subtaskId);
466
- if (attempts.length > 0) {
467
- const last = attempts[attempts.length - 1];
468
- if (last?.trace === undefined) {
469
- return refused("missing-trace");
470
- }
399
+ if (input.kind === "unit") {
400
+ const last = aggregate.attempts[aggregate.attempts.length - 1];
401
+ if (last !== undefined && last.trace === undefined) {
402
+ return refused("missing-trace");
471
403
  }
472
404
  }
473
405
  const bornInMerging = aggregate.state === "merging" || input.kind === "merging";
@@ -476,26 +408,17 @@ export function openWorkLedger(options) {
476
408
  kind: input.kind,
477
409
  born_in_merging: bornInMerging,
478
410
  };
479
- if (input.subtaskId !== undefined) {
480
- escalation.subtask_id = input.subtaskId;
481
- }
482
411
  if (input.report !== undefined && input.report.trim().length > 0) {
483
412
  escalation.report = input.report;
484
413
  }
485
414
  aggregate.escalation = escalation;
486
415
  // Freezing for a human consumes the in-flight update signal. Resume
487
- // continues the stored intention; a newer FeatureStandard lands by admit.
416
+ // continues the stored intention; a newer TicketStandard lands by admit.
488
417
  delete aggregate.pending_fingerprint;
489
- if (aggregate.plan !== undefined && input.subtaskId !== undefined) {
490
- const subtask = aggregate.plan.subtasks.find((st) => st.id === input.subtaskId);
491
- if (subtask !== undefined && subtask.state === "running") {
492
- subtask.state = "escalated";
493
- }
494
- }
495
418
  delete aggregate.bail;
496
419
  return await write(aggregate);
497
420
  },
498
- async resumeReady(key) {
421
+ async resumeTicket(key) {
499
422
  const current = await loadOrRefuse(key);
500
423
  if (!current.ok) {
501
424
  return current;
@@ -507,33 +430,14 @@ export function openWorkLedger(options) {
507
430
  if (aggregate.state !== "escalated" || aggregate.escalation === undefined) {
508
431
  return refused("illegal-transition");
509
432
  }
510
- const { kind, subtask_id } = aggregate.escalation;
511
- aggregate.resumes = (aggregate.resumes ?? 0) + 1;
512
- if (kind === "plan") {
513
- delete aggregate.plan;
514
- aggregate.state = "planning";
515
- delete aggregate.invalid;
516
- delete aggregate.escalation;
433
+ const { kind } = aggregate.escalation;
434
+ aggregate.resumes += 1;
435
+ delete aggregate.escalation;
436
+ aggregate.state = kind === "unit" ? "running" : "integrating";
437
+ if (aggregate.state === "integrating") {
517
438
  aggregate.bail = { expires_at: now() + bailDurationMs };
518
- return await write(aggregate);
519
439
  }
520
- if (kind === "merging" || kind === "submitted" || kind === "assembly") {
521
- aggregate.state = "integrating";
522
- delete aggregate.escalation;
523
- aggregate.bail = { expires_at: now() + bailDurationMs };
524
- return await write(aggregate);
525
- }
526
- if (kind === "subtask" && subtask_id !== undefined && aggregate.plan !== undefined) {
527
- const subtask = aggregate.plan.subtasks.find((st) => st.id === subtask_id);
528
- if (subtask !== undefined) {
529
- subtask.state = "runnable";
530
- }
531
- aggregate.state = "running";
532
- delete aggregate.escalation;
533
- aggregate.bail = { expires_at: now() + bailDurationMs };
534
- return await write(aggregate);
535
- }
536
- return refused("illegal-transition");
440
+ return await write(aggregate);
537
441
  },
538
442
  async cancel(key) {
539
443
  const current = await loadOrRefuse(key);
@@ -551,9 +455,6 @@ export function openWorkLedger(options) {
551
455
  return { ok: true };
552
456
  }
553
457
  aggregate.state = "cancelled";
554
- if (aggregate.plan !== undefined) {
555
- aggregate.plan.subtasks = aggregate.plan.subtasks.map((st) => st.state === "integrated" ? st : { ...st, state: "cancelled" });
556
- }
557
458
  delete aggregate.bail;
558
459
  return await write(aggregate);
559
460
  },
@@ -566,35 +467,19 @@ export function openWorkLedger(options) {
566
467
  if (aggregate.state === "done" || aggregate.state === "cancelled") {
567
468
  return refused("illegal-transition");
568
469
  }
569
- const workspaces = { ...aggregate.workspaces };
570
- if (input.feature !== undefined) {
571
- workspaces.feature = input.feature;
572
- }
573
- if (input.subtask !== undefined) {
574
- workspaces.subtask = input.subtask;
575
- }
576
- aggregate.workspaces = workspaces;
470
+ aggregate.workspace = input.path;
577
471
  return await write(aggregate);
578
472
  },
579
- async clearWorkspace(input) {
580
- const current = await loadOrRefuse(input.key);
473
+ async clearWorkspace(key) {
474
+ const current = await loadOrRefuse(key);
581
475
  if (!current.ok) {
582
476
  return current;
583
477
  }
584
478
  const { aggregate } = current;
585
- if (aggregate.workspaces === undefined) {
479
+ if (aggregate.workspace === undefined) {
586
480
  return { ok: true };
587
481
  }
588
- if (input.feature === true) {
589
- delete aggregate.workspaces.feature;
590
- }
591
- if (input.subtask === true) {
592
- delete aggregate.workspaces.subtask;
593
- }
594
- if (aggregate.workspaces.feature === undefined &&
595
- aggregate.workspaces.subtask === undefined) {
596
- delete aggregate.workspaces;
597
- }
482
+ delete aggregate.workspace;
598
483
  return await write(aggregate);
599
484
  },
600
485
  async expireBail(key) {
@@ -606,19 +491,9 @@ export function openWorkLedger(options) {
606
491
  if (aggregate.bail === undefined || now() < aggregate.bail.expires_at) {
607
492
  return refused("illegal-transition");
608
493
  }
609
- if (aggregate.state === "planning") {
610
- aggregate.state = "received";
494
+ if (!releaseRunningUnit(aggregate)) {
611
495
  delete aggregate.bail;
612
- return await write(aggregate);
613
- }
614
- if (aggregate.state === "running" && aggregate.plan !== undefined) {
615
- if (!releaseRunningSubtask(aggregate)) {
616
- clearSubtaskWorkspace(aggregate);
617
- delete aggregate.bail;
618
- }
619
- return await write(aggregate);
620
496
  }
621
- delete aggregate.bail;
622
497
  return await write(aggregate);
623
498
  },
624
499
  async releaseBail(key) {
@@ -627,19 +502,14 @@ export function openWorkLedger(options) {
627
502
  return current;
628
503
  }
629
504
  const { aggregate } = current;
630
- if (aggregate.state === "planning") {
631
- aggregate.state = "received";
632
- delete aggregate.bail;
633
- return await write(aggregate);
505
+ if (aggregate.state !== "running") {
506
+ return refused("illegal-transition");
634
507
  }
635
- if (aggregate.state === "running" && aggregate.plan !== undefined) {
636
- if (!releaseRunningSubtask(aggregate)) {
637
- // Already free: no Subtask held, nothing to write.
638
- return { ok: true };
639
- }
640
- return await write(aggregate);
508
+ if (!releaseRunningUnit(aggregate)) {
509
+ // Already free: no unit held, nothing to write.
510
+ return { ok: true };
641
511
  }
642
- return refused("illegal-transition");
512
+ return await write(aggregate);
643
513
  },
644
514
  async renewBail(key) {
645
515
  const current = await loadOrRefuse(key);
@@ -654,64 +524,45 @@ export function openWorkLedger(options) {
654
524
  return await write(aggregate);
655
525
  },
656
526
  async get(key) {
657
- const found = await persist.load(key);
527
+ const found = await loadTicket(key);
658
528
  if (found === undefined) {
659
529
  return { ok: false, code: "not-found" };
660
530
  }
661
531
  return { ok: true, aggregate: found };
662
532
  },
663
533
  async list() {
664
- return await persist.listSummaries();
534
+ return ticketsOf(await persist.listSummaries());
665
535
  },
666
536
  async listInFlight() {
667
- const summaries = await persist.listSummaries();
537
+ const summaries = ticketsOf(await persist.listSummaries());
668
538
  return summaries.filter((summary) => IN_FLIGHT.has(summary.state));
669
539
  },
670
540
  async activeOn(project) {
671
- const summaries = await persist.listSummaries();
541
+ const summaries = ticketsOf(await persist.listSummaries());
672
542
  return summaries.find((s) => s.project === project && ACTIVE_STATES.has(s.state));
673
543
  },
674
544
  async nextReceived(project) {
675
- const summaries = await persist.listSummaries();
545
+ const summaries = ticketsOf(await persist.listSummaries());
676
546
  if (projectIsBusy(summaries, project)) {
677
547
  return undefined;
678
548
  }
679
- return pickReceived(summaries, project);
680
- },
681
- async nextRunnable(key) {
682
- const found = await persist.load(key);
683
- if (found?.plan === undefined) {
684
- return undefined;
685
- }
686
- const runnable = found.plan.subtasks.filter((st) => st.state === "runnable");
687
- if (runnable.length === 0) {
688
- return undefined;
689
- }
690
- runnable.sort((a, b) => unblocksCount(b.id, found.plan?.subtasks ?? []) -
691
- unblocksCount(a.id, found.plan?.subtasks ?? []));
692
- return runnable[0]?.id;
549
+ const picked = await pickReceived(summaries, project);
550
+ return picked === undefined
551
+ ? undefined
552
+ : summaries.find((summary) => summary.key === picked.intention.key);
693
553
  },
694
554
  async listDeclaredWorkspaces() {
695
- const summaries = await persist.listSummaries();
555
+ const summaries = ticketsOf(await persist.listSummaries());
696
556
  const listed = [];
697
557
  for (const summary of summaries) {
698
- const aggregate = await persist.load(summary.key);
699
- if (aggregate?.workspaces === undefined) {
700
- continue;
701
- }
702
- const row = { key: summary.key };
703
- if (aggregate.workspaces.feature !== undefined) {
704
- row.feature = aggregate.workspaces.feature;
705
- }
706
- if (aggregate.workspaces.subtask !== undefined) {
707
- row.subtask = aggregate.workspaces.subtask;
708
- }
709
- if (row.feature !== undefined || row.subtask !== undefined) {
710
- listed.push(row);
558
+ const aggregate = await loadTicket(summary.key);
559
+ if (aggregate?.workspace !== undefined) {
560
+ listed.push({ key: summary.key, path: aggregate.workspace });
711
561
  }
712
562
  }
713
563
  return listed;
714
564
  },
565
+ ...openFeatureCommands({ persist, now }),
715
566
  };
716
567
  }
717
568
  //# sourceMappingURL=open-work-ledger.js.map