@bluewombat/work-ledger 0.8.0 → 0.10.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,58 @@
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 };
32
+ }
33
+ if (ticket.feature !== undefined) {
34
+ intention.feature = ticket.feature;
35
+ }
36
+ if (ticket.blocked_by !== undefined) {
37
+ intention.blocked_by = [...ticket.blocked_by];
30
38
  }
31
- if (feature.manager !== undefined) {
32
- intention.manager = feature.manager;
39
+ if (ticket.line !== undefined) {
40
+ intention.line = ticket.line;
33
41
  }
34
- if (feature.external_id !== undefined) {
35
- intention.external_id = feature.external_id;
42
+ if (ticket.no_tests === true) {
43
+ intention.no_tests = true;
36
44
  }
37
- if (feature.source !== undefined) {
38
- intention.source = { ...feature.source };
45
+ if (ticket.e2e === true) {
46
+ intention.e2e = true;
39
47
  }
40
48
  return intention;
41
49
  }
42
- function pickReceived(summaries, project) {
50
+ /** The Tickets among what the Port lists: a Feature shares the Port, not the queries. */
51
+ function ticketsOf(summaries) {
52
+ return summaries.filter((summary) => !isFeatureSummary(summary));
53
+ }
54
+ /** Received Tickets of a Project in the order they would start: priority, then age. */
55
+ function receivedOf(summaries, project) {
43
56
  const candidates = summaries.filter((s) => s.project === project && s.state === "received");
44
57
  candidates.sort((a, b) => {
45
58
  if (b.priority !== a.priority) {
@@ -47,38 +60,32 @@ function pickReceived(summaries, project) {
47
60
  }
48
61
  return a.received_at - b.received_at;
49
62
  });
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;
63
+ return candidates;
57
64
  }
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
- }
65
+ /**
66
+ * Whether a Project has no room for another Ticket: `limit` of them are
67
+ * already active. An escalated Ticket is active, so it holds its place until a
68
+ * person resolves it; with a limit of 1 that is the Project stopping, as ever.
69
+ */
70
+ function projectIsBusy(summaries, project, limit = 1) {
71
+ return (summaries.filter((s) => s.project === project && ACTIVE_STATES.has(s.state)).length >= limit);
67
72
  }
68
73
  /**
69
- * A held Subtask becomes runnable from zero. Returns whether one was held.
74
+ * A held unit starts over from zero: its directory is forgotten, the Attempt
75
+ * it was in is spent, and the lease is gone. Returns whether one was held.
70
76
  */
71
- function releaseRunningSubtask(aggregate) {
72
- if (aggregate.plan === undefined) {
77
+ /**
78
+ * The held unit stops. Its workspace stays declared: it is a directory the ledger knows nothing
79
+ * of, and what it holds is the next run's to look at. Whoever wants it gone says so (`clearWorkspace`).
80
+ * `spend`: an unit that ended without recording its Attempts spent one.
81
+ */
82
+ function releaseRunningUnit(aggregate, spend) {
83
+ if (aggregate.state !== "running" || aggregate.bail === undefined) {
73
84
  return false;
74
85
  }
75
- const running = aggregate.plan.subtasks.find((st) => st.state === "running");
76
- if (running === undefined) {
77
- return false;
86
+ if (spend) {
87
+ aggregate.attempts_used += 1;
78
88
  }
79
- running.state = "runnable";
80
- aggregate.attempts_used += 1;
81
- clearSubtaskWorkspace(aggregate);
82
89
  delete aggregate.bail;
83
90
  return true;
84
91
  }
@@ -86,6 +93,37 @@ export function openWorkLedger(options) {
86
93
  const persist = options.persist;
87
94
  const now = options.now ?? (() => Date.now());
88
95
  const bailDurationMs = options.bailDurationMs ?? DEFAULT_BAIL_DURATION_MS;
96
+ /** One claim at a time, in the order asked. */
97
+ let lastClaim = Promise.resolve();
98
+ const claiming = (job) => {
99
+ const run = lastClaim.then(job, job);
100
+ lastClaim = run.catch(() => undefined);
101
+ return run;
102
+ };
103
+ const loadTicket = async (key) => {
104
+ const found = await persist.load(key);
105
+ return found === undefined || isFeatureAggregate(found) ? undefined : found;
106
+ };
107
+ /** Whether every Ticket this one waits on is `done`. A blocker nobody knows blocks. */
108
+ const isUnblocked = async (aggregate) => {
109
+ for (const blocker of aggregate.intention.blocked_by ?? []) {
110
+ const found = await loadTicket(blocker);
111
+ if (found?.state !== "done") {
112
+ return false;
113
+ }
114
+ }
115
+ return true;
116
+ };
117
+ /** The first received Ticket of the Project that may start. */
118
+ const pickReceived = async (summaries, project) => {
119
+ for (const candidate of receivedOf(summaries, project)) {
120
+ const loaded = await loadTicket(candidate.key);
121
+ if (loaded !== undefined && loaded.state === "received" && (await isUnblocked(loaded))) {
122
+ return loaded;
123
+ }
124
+ }
125
+ return undefined;
126
+ };
89
127
  const write = async (aggregate) => {
90
128
  const saved = await persist.save(aggregate.intention.key, aggregate);
91
129
  if (!saved.ok) {
@@ -94,22 +132,23 @@ export function openWorkLedger(options) {
94
132
  return { ok: true };
95
133
  };
96
134
  const loadOrRefuse = async (key) => {
97
- const found = await persist.load(key);
135
+ const found = await loadTicket(key);
98
136
  if (found === undefined) {
99
137
  return refused("not-found");
100
138
  }
101
139
  return { ok: true, aggregate: structuredClone(found) };
102
140
  };
103
141
  return {
104
- async admit(feature) {
105
- const existing = await persist.load(feature.key);
142
+ async admit(ticket) {
143
+ const existing = await loadTicket(ticket.key);
106
144
  if (existing === undefined) {
107
145
  const aggregate = {
108
- intention: cloneIntention(feature),
146
+ intention: cloneIntention(ticket),
109
147
  state: "received",
110
148
  received_at: now(),
111
149
  attempts: [],
112
150
  attempts_used: 0,
151
+ resumes: 0,
113
152
  };
114
153
  return await write(aggregate);
115
154
  }
@@ -120,24 +159,24 @@ export function openWorkLedger(options) {
120
159
  return refused("illegal-transition");
121
160
  }
122
161
  if (IN_FLIGHT.has(existing.state)) {
123
- if (feature.fingerprint === existing.intention.fingerprint ||
124
- feature.fingerprint === existing.pending_fingerprint) {
162
+ if (ticket.fingerprint === existing.intention.fingerprint ||
163
+ ticket.fingerprint === existing.pending_fingerprint) {
125
164
  // Same body as the live intention, or as an edit already recorded:
126
- // an echo of a report, not a newer FeatureStandard.
165
+ // an echo of a report, not a newer TicketStandard.
127
166
  return { ok: true };
128
167
  }
129
168
  const next = structuredClone(existing);
130
- next.pending_fingerprint = feature.fingerprint;
169
+ next.pending_fingerprint = ticket.fingerprint;
131
170
  return await write(next);
132
171
  }
133
- if (existing.state === "received" && existing.intention.fingerprint === feature.fingerprint) {
172
+ if (existing.state === "received" && existing.intention.fingerprint === ticket.fingerprint) {
134
173
  return { ok: true };
135
174
  }
136
175
  if (existing.state === "received" ||
137
176
  existing.state === "invalid" ||
138
177
  existing.state === "escalated") {
139
178
  const next = structuredClone(existing);
140
- next.intention = cloneIntention(feature);
179
+ next.intention = cloneIntention(ticket);
141
180
  next.state = "received";
142
181
  delete next.invalid;
143
182
  delete next.pending_fingerprint;
@@ -146,7 +185,7 @@ export function openWorkLedger(options) {
146
185
  return refused("illegal-transition");
147
186
  },
148
187
  async recordInvalid(input) {
149
- const existing = await persist.load(input.key);
188
+ const existing = await loadTicket(input.key);
150
189
  if (existing !== undefined && (existing.state === "merging" || existing.state === "done")) {
151
190
  return refused("too-late");
152
191
  }
@@ -163,6 +202,7 @@ export function openWorkLedger(options) {
163
202
  received_at: now(),
164
203
  attempts: [],
165
204
  attempts_used: 0,
205
+ resumes: 0,
166
206
  invalid: { code: input.code, reason: input.reason },
167
207
  };
168
208
  return await write(aggregate);
@@ -180,153 +220,82 @@ export function openWorkLedger(options) {
180
220
  delete next.bail;
181
221
  return await write(next);
182
222
  },
183
- async claim(project) {
184
- const summaries = await persist.listSummaries();
185
- if (projectIsBusy(summaries, project)) {
186
- return refused("project-busy");
187
- }
188
- const picked = pickReceived(summaries, project);
189
- if (picked === undefined) {
190
- return { ok: true, empty: true };
191
- }
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 };
199
- const saved = await write(next);
200
- if (!saved.ok) {
201
- return saved;
202
- }
203
- return { ok: true, key: picked.key };
223
+ async claim(project, limit = 1) {
224
+ // Reading the Project and marking the Ticket are two steps; a second
225
+ // claim between them would take the same Ticket or exceed the limit.
226
+ return await claiming(async () => {
227
+ const summaries = ticketsOf(await persist.listSummaries());
228
+ if (projectIsBusy(summaries, project, limit)) {
229
+ return refused("project-busy");
230
+ }
231
+ const picked = await pickReceived(summaries, project);
232
+ if (picked === undefined) {
233
+ return { ok: true, empty: true };
234
+ }
235
+ const next = structuredClone(picked);
236
+ next.state = "running";
237
+ const saved = await write(next);
238
+ if (!saved.ok) {
239
+ return saved;
240
+ }
241
+ return { ok: true, key: picked.intention.key };
242
+ });
204
243
  },
205
- async recordPlan(input) {
244
+ async recordAttempt(input) {
206
245
  const current = await loadOrRefuse(input.key);
207
246
  if (!current.ok) {
208
247
  return current;
209
248
  }
210
249
  const { aggregate } = current;
211
- if (aggregate.state !== "planning") {
250
+ if (aggregate.state !== "running") {
212
251
  return refused("illegal-transition");
213
252
  }
214
- if (aggregate.plan !== undefined) {
215
- return refused("plan-frozen");
216
- }
217
- const checked = checkPlan(input.subtasks);
218
- if (!checked.ok) {
253
+ if (input.number !== aggregate.attempts.length + 1) {
219
254
  return refused("illegal-transition");
220
255
  }
221
- aggregate.plan = {
222
- planned_at: input.plannedAt,
223
- subtasks: checked.subtasks.map((draft) => ({
224
- ...draft,
225
- state: initialSubtaskState(draft),
226
- })),
256
+ const attempt = {
257
+ number: input.number,
258
+ round: aggregate.resumes,
227
259
  };
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");
260
+ if (input.trace !== undefined) {
261
+ attempt.trace = input.trace;
240
262
  }
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;
263
+ aggregate.attempts.push(attempt);
264
+ aggregate.attempts_used += 1;
245
265
  return await write(aggregate);
246
266
  },
247
- async startSubtask(input) {
248
- const current = await loadOrRefuse(input.key);
267
+ async startUnit(key) {
268
+ const current = await loadOrRefuse(key);
249
269
  if (!current.ok) {
250
270
  return current;
251
271
  }
252
272
  const { aggregate } = current;
253
- if (aggregate.plan === undefined) {
254
- return refused("plan-not-frozen");
255
- }
256
273
  if (aggregate.state !== "running") {
257
274
  return refused("illegal-transition");
258
275
  }
259
- if (aggregate.plan.subtasks.some((st) => st.state === "running")) {
276
+ if (aggregate.bail !== undefined) {
260
277
  return refused("another-running");
261
278
  }
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
279
  aggregate.bail = { expires_at: now() + bailDurationMs };
271
280
  return await write(aggregate);
272
281
  },
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);
282
+ async markIntegrating(key) {
283
+ const current = await loadOrRefuse(key);
305
284
  if (!current.ok) {
306
285
  return current;
307
286
  }
308
287
  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") {
288
+ if (aggregate.state !== "running") {
314
289
  return refused("illegal-transition");
315
290
  }
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
- }
291
+ aggregate.state = "integrating";
323
292
  aggregate.bail = { expires_at: now() + bailDurationMs };
324
293
  return await write(aggregate);
325
294
  },
326
295
  /**
327
- * The assembled feature is now in front of the Authority.
296
+ * The assembled ticket is now in front of the Authority.
328
297
  *
329
- * A second call on an already submitted feature keeps the first reference:
298
+ * A second call on an already submitted ticket keeps the first reference:
330
299
  * the work was pushed again to the same place, and the Authority is judging
331
300
  * the same Submission.
332
301
  */
@@ -343,7 +312,7 @@ export function openWorkLedger(options) {
343
312
  // Rebuilt field by field, so anything added here has to be carried on
344
313
  // purpose. What was already sent back is carried: a Submission offered
345
314
  // again is the same Submission, and dropping the reason leaves whoever
346
- // reads the next escalation with a frozen Feature and no cause.
315
+ // reads the next escalation with a frozen Ticket and no cause.
347
316
  const previous = aggregate.submission;
348
317
  aggregate.submission = {
349
318
  reference: previous?.reference ?? input.reference,
@@ -353,13 +322,14 @@ export function openWorkLedger(options) {
353
322
  ...(previous?.last_refused_by === undefined
354
323
  ? {}
355
324
  : { last_refused_by: previous.last_refused_by }),
325
+ ...(previous?.realigns === undefined ? {} : { realigns: previous.realigns }),
356
326
  };
357
327
  aggregate.bail = { expires_at: now() + bailDurationMs };
358
328
  return await write(aggregate);
359
329
  },
360
330
  /**
361
331
  * 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.
332
+ * and the ticket returns to integrating, which is where the work is made.
363
333
  */
364
334
  async recordRefusal(input) {
365
335
  const current = await loadOrRefuse(input.key);
@@ -384,6 +354,30 @@ export function openWorkLedger(options) {
384
354
  aggregate.bail = { expires_at: now() + bailDurationMs };
385
355
  return await write(aggregate);
386
356
  },
357
+ /**
358
+ * The line the Submission is offered to moved on while it waited. Nothing was
359
+ * refused, so no report is kept and no fix is owed: the ticket returns to
360
+ * `integrating`, where the next pass brings it up to date with the line.
361
+ *
362
+ * The report of an earlier refusal is dropped on purpose: it was repaired
363
+ * already, and left in place it would send the next round to repair it again.
364
+ */
365
+ async recordRealign(input) {
366
+ const current = await loadOrRefuse(input.key);
367
+ if (!current.ok) {
368
+ return current;
369
+ }
370
+ const { aggregate } = current;
371
+ if (aggregate.state !== "submitted" || aggregate.submission === undefined) {
372
+ return refused("illegal-transition");
373
+ }
374
+ const { last_report: _report, last_refused_by: _by, ...kept } = aggregate.submission;
375
+ aggregate.submission = { ...kept, realigns: (kept.realigns ?? 0) + 1 };
376
+ aggregate.state = "integrating";
377
+ delete aggregate.parked_refusal;
378
+ aggregate.bail = { expires_at: now() + bailDurationMs };
379
+ return await write(aggregate);
380
+ },
387
381
  /**
388
382
  * `assembly.validate` sent the work back, before any Submission exists to
389
383
  * hold the reason. Shares the `maxRefusals` budget with `recordRefusal`.
@@ -404,7 +398,7 @@ export function openWorkLedger(options) {
404
398
  aggregate.parked_refusals = (aggregate.parked_refusals ?? 0) + 1;
405
399
  return await write(aggregate);
406
400
  },
407
- /** `assembly.validate` accepted the feature. The counter is the budget spent; it stays. */
401
+ /** `assembly.validate` accepted the ticket. The counter is the budget spent; it stays. */
408
402
  async clearParkedRefusal(key) {
409
403
  const current = await loadOrRefuse(key);
410
404
  if (!current.ok) {
@@ -444,12 +438,6 @@ export function openWorkLedger(options) {
444
438
  aggregate.integration_reference = input.reference;
445
439
  }
446
440
  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
441
  return await write(aggregate);
454
442
  },
455
443
  async escalate(input) {
@@ -461,13 +449,10 @@ export function openWorkLedger(options) {
461
449
  if (aggregate.state === "done" || aggregate.state === "cancelled") {
462
450
  return refused("illegal-transition");
463
451
  }
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
- }
452
+ if (input.kind === "unit") {
453
+ const last = aggregate.attempts[aggregate.attempts.length - 1];
454
+ if (last !== undefined && last.trace === undefined) {
455
+ return refused("missing-trace");
471
456
  }
472
457
  }
473
458
  const bornInMerging = aggregate.state === "merging" || input.kind === "merging";
@@ -476,26 +461,17 @@ export function openWorkLedger(options) {
476
461
  kind: input.kind,
477
462
  born_in_merging: bornInMerging,
478
463
  };
479
- if (input.subtaskId !== undefined) {
480
- escalation.subtask_id = input.subtaskId;
481
- }
482
464
  if (input.report !== undefined && input.report.trim().length > 0) {
483
465
  escalation.report = input.report;
484
466
  }
485
467
  aggregate.escalation = escalation;
486
468
  // Freezing for a human consumes the in-flight update signal. Resume
487
- // continues the stored intention; a newer FeatureStandard lands by admit.
469
+ // continues the stored intention; a newer TicketStandard lands by admit.
488
470
  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
471
  delete aggregate.bail;
496
472
  return await write(aggregate);
497
473
  },
498
- async resumeReady(key) {
474
+ async resumeTicket(key) {
499
475
  const current = await loadOrRefuse(key);
500
476
  if (!current.ok) {
501
477
  return current;
@@ -507,33 +483,19 @@ export function openWorkLedger(options) {
507
483
  if (aggregate.state !== "escalated" || aggregate.escalation === undefined) {
508
484
  return refused("illegal-transition");
509
485
  }
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;
517
- aggregate.bail = { expires_at: now() + bailDurationMs };
518
- return await write(aggregate);
519
- }
520
- if (kind === "merging" || kind === "submitted" || kind === "assembly") {
521
- aggregate.state = "integrating";
522
- delete aggregate.escalation;
486
+ const { kind } = aggregate.escalation;
487
+ aggregate.resumes += 1;
488
+ delete aggregate.escalation;
489
+ aggregate.state = kind === "unit" ? "running" : "integrating";
490
+ if (aggregate.state === "integrating") {
523
491
  aggregate.bail = { expires_at: now() + bailDurationMs };
524
- return await write(aggregate);
525
492
  }
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);
493
+ else {
494
+ // A human's resume of a unit starts it from zero: what it left is not picked up.
495
+ delete aggregate.workspace;
496
+ delete aggregate.first_stage_done;
535
497
  }
536
- return refused("illegal-transition");
498
+ return await write(aggregate);
537
499
  },
538
500
  async cancel(key) {
539
501
  const current = await loadOrRefuse(key);
@@ -551,9 +513,6 @@ export function openWorkLedger(options) {
551
513
  return { ok: true };
552
514
  }
553
515
  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
516
  delete aggregate.bail;
558
517
  return await write(aggregate);
559
518
  },
@@ -566,35 +525,34 @@ export function openWorkLedger(options) {
566
525
  if (aggregate.state === "done" || aggregate.state === "cancelled") {
567
526
  return refused("illegal-transition");
568
527
  }
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;
528
+ aggregate.workspace = input.path;
529
+ // Declared anew: nothing is done in it yet.
530
+ delete aggregate.first_stage_done;
577
531
  return await write(aggregate);
578
532
  },
579
- async clearWorkspace(input) {
580
- const current = await loadOrRefuse(input.key);
533
+ async clearWorkspace(key) {
534
+ const current = await loadOrRefuse(key);
581
535
  if (!current.ok) {
582
536
  return current;
583
537
  }
584
538
  const { aggregate } = current;
585
- if (aggregate.workspaces === undefined) {
539
+ if (aggregate.workspace === undefined) {
586
540
  return { ok: true };
587
541
  }
588
- if (input.feature === true) {
589
- delete aggregate.workspaces.feature;
590
- }
591
- if (input.subtask === true) {
592
- delete aggregate.workspaces.subtask;
542
+ delete aggregate.workspace;
543
+ delete aggregate.first_stage_done;
544
+ return await write(aggregate);
545
+ },
546
+ async markFirstStageDone(key) {
547
+ const current = await loadOrRefuse(key);
548
+ if (!current.ok) {
549
+ return current;
593
550
  }
594
- if (aggregate.workspaces.feature === undefined &&
595
- aggregate.workspaces.subtask === undefined) {
596
- delete aggregate.workspaces;
551
+ const { aggregate } = current;
552
+ if (aggregate.state !== "running" || aggregate.workspace === undefined) {
553
+ return refused("illegal-transition");
597
554
  }
555
+ aggregate.first_stage_done = true;
598
556
  return await write(aggregate);
599
557
  },
600
558
  async expireBail(key) {
@@ -606,19 +564,9 @@ export function openWorkLedger(options) {
606
564
  if (aggregate.bail === undefined || now() < aggregate.bail.expires_at) {
607
565
  return refused("illegal-transition");
608
566
  }
609
- if (aggregate.state === "planning") {
610
- aggregate.state = "received";
567
+ if (!releaseRunningUnit(aggregate, true)) {
611
568
  delete aggregate.bail;
612
- return await write(aggregate);
613
569
  }
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
- }
621
- delete aggregate.bail;
622
570
  return await write(aggregate);
623
571
  },
624
572
  async releaseBail(key) {
@@ -627,19 +575,14 @@ export function openWorkLedger(options) {
627
575
  return current;
628
576
  }
629
577
  const { aggregate } = current;
630
- if (aggregate.state === "planning") {
631
- aggregate.state = "received";
632
- delete aggregate.bail;
633
- return await write(aggregate);
578
+ if (aggregate.state !== "running") {
579
+ return refused("illegal-transition");
634
580
  }
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);
581
+ if (!releaseRunningUnit(aggregate, false)) {
582
+ // Already free: no unit held, nothing to write.
583
+ return { ok: true };
641
584
  }
642
- return refused("illegal-transition");
585
+ return await write(aggregate);
643
586
  },
644
587
  async renewBail(key) {
645
588
  const current = await loadOrRefuse(key);
@@ -654,64 +597,49 @@ export function openWorkLedger(options) {
654
597
  return await write(aggregate);
655
598
  },
656
599
  async get(key) {
657
- const found = await persist.load(key);
600
+ const found = await loadTicket(key);
658
601
  if (found === undefined) {
659
602
  return { ok: false, code: "not-found" };
660
603
  }
661
604
  return { ok: true, aggregate: found };
662
605
  },
663
606
  async list() {
664
- return await persist.listSummaries();
607
+ return ticketsOf(await persist.listSummaries());
665
608
  },
666
609
  async listInFlight() {
667
- const summaries = await persist.listSummaries();
610
+ const summaries = ticketsOf(await persist.listSummaries());
668
611
  return summaries.filter((summary) => IN_FLIGHT.has(summary.state));
669
612
  },
613
+ async activeTickets(project) {
614
+ const summaries = ticketsOf(await persist.listSummaries());
615
+ return summaries.filter((s) => s.project === project && ACTIVE_STATES.has(s.state));
616
+ },
670
617
  async activeOn(project) {
671
- const summaries = await persist.listSummaries();
618
+ const summaries = ticketsOf(await persist.listSummaries());
672
619
  return summaries.find((s) => s.project === project && ACTIVE_STATES.has(s.state));
673
620
  },
674
621
  async nextReceived(project) {
675
- const summaries = await persist.listSummaries();
622
+ const summaries = ticketsOf(await persist.listSummaries());
676
623
  if (projectIsBusy(summaries, project)) {
677
624
  return undefined;
678
625
  }
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;
626
+ const picked = await pickReceived(summaries, project);
627
+ return picked === undefined
628
+ ? undefined
629
+ : summaries.find((summary) => summary.key === picked.intention.key);
693
630
  },
694
631
  async listDeclaredWorkspaces() {
695
- const summaries = await persist.listSummaries();
632
+ const summaries = ticketsOf(await persist.listSummaries());
696
633
  const listed = [];
697
634
  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);
635
+ const aggregate = await loadTicket(summary.key);
636
+ if (aggregate?.workspace !== undefined) {
637
+ listed.push({ key: summary.key, path: aggregate.workspace });
711
638
  }
712
639
  }
713
640
  return listed;
714
641
  },
642
+ ...openFeatureCommands({ persist, now }),
715
643
  };
716
644
  }
717
645
  //# sourceMappingURL=open-work-ledger.js.map