@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.
- package/README.md +1 -1
- package/SPECS.md +112 -93
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/ledger/feature-commands.d.ts +50 -0
- package/dist/ledger/feature-commands.d.ts.map +1 -0
- package/dist/ledger/feature-commands.js +196 -0
- package/dist/ledger/feature-commands.js.map +1 -0
- package/dist/ledger/open-work-ledger.d.ts +24 -54
- package/dist/ledger/open-work-ledger.d.ts.map +1 -1
- package/dist/ledger/open-work-ledger.js +141 -290
- package/dist/ledger/open-work-ledger.js.map +1 -1
- package/dist/ledger/results.d.ts +11 -0
- package/dist/ledger/results.d.ts.map +1 -0
- package/dist/ledger/results.js +4 -0
- package/dist/ledger/results.js.map +1 -0
- package/dist/persist/port.d.ts +4 -4
- package/dist/persist/port.d.ts.map +1 -1
- package/dist/records.d.ts +90 -41
- package/dist/records.d.ts.map +1 -1
- package/dist/records.js +6 -1
- package/dist/records.js.map +1 -1
- package/package.json +2 -2
- package/dist/ledger/check-plan.d.ts +0 -17
- package/dist/ledger/check-plan.d.ts.map +0 -1
- package/dist/ledger/check-plan.js +0 -73
- package/dist/ledger/check-plan.js.map +0 -1
|
@@ -1,45 +1,52 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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:
|
|
23
|
-
project:
|
|
24
|
-
fingerprint:
|
|
25
|
-
priority:
|
|
26
|
-
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 (
|
|
29
|
-
intention.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
|
|
32
|
-
intention.
|
|
33
|
+
if (ticket.feature !== undefined) {
|
|
34
|
+
intention.feature = ticket.feature;
|
|
33
35
|
}
|
|
34
|
-
if (
|
|
35
|
-
intention.
|
|
36
|
+
if (ticket.blocked_by !== undefined) {
|
|
37
|
+
intention.blocked_by = [...ticket.blocked_by];
|
|
36
38
|
}
|
|
37
|
-
if (
|
|
38
|
-
intention.
|
|
39
|
+
if (ticket.line !== undefined) {
|
|
40
|
+
intention.line = ticket.line;
|
|
39
41
|
}
|
|
40
42
|
return intention;
|
|
41
43
|
}
|
|
42
|
-
|
|
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
|
|
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
|
|
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
|
|
72
|
-
if (aggregate.
|
|
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
|
-
|
|
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
|
|
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(
|
|
105
|
-
const existing = await
|
|
118
|
+
async admit(ticket) {
|
|
119
|
+
const existing = await loadTicket(ticket.key);
|
|
106
120
|
if (existing === undefined) {
|
|
107
121
|
const aggregate = {
|
|
108
|
-
intention: cloneIntention(
|
|
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 (
|
|
124
|
-
|
|
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
|
|
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 =
|
|
145
|
+
next.pending_fingerprint = ticket.fingerprint;
|
|
131
146
|
return await write(next);
|
|
132
147
|
}
|
|
133
|
-
if (existing.state === "received" && existing.intention.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(
|
|
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
|
|
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
|
|
193
|
-
|
|
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
|
|
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 !== "
|
|
222
|
+
if (aggregate.state !== "running") {
|
|
212
223
|
return refused("illegal-transition");
|
|
213
224
|
}
|
|
214
|
-
if (
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
...draft,
|
|
225
|
-
state: initialSubtaskState(draft),
|
|
226
|
-
})),
|
|
228
|
+
const attempt = {
|
|
229
|
+
number: input.number,
|
|
230
|
+
round: aggregate.resumes,
|
|
227
231
|
};
|
|
228
|
-
|
|
229
|
-
|
|
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.
|
|
242
|
-
aggregate.
|
|
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
|
|
248
|
-
const current = await loadOrRefuse(
|
|
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.
|
|
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
|
|
274
|
-
const current = await loadOrRefuse(
|
|
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"
|
|
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
|
-
|
|
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
|
|
268
|
+
* The assembled ticket is now in front of the Authority.
|
|
328
269
|
*
|
|
329
|
-
* A second call on an already submitted
|
|
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
|
|
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
|
|
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
|
|
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 === "
|
|
465
|
-
const
|
|
466
|
-
if (
|
|
467
|
-
|
|
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
|
|
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
|
|
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
|
|
511
|
-
aggregate.resumes
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
580
|
-
const current = await loadOrRefuse(
|
|
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.
|
|
479
|
+
if (aggregate.workspace === undefined) {
|
|
586
480
|
return { ok: true };
|
|
587
481
|
}
|
|
588
|
-
|
|
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
|
|
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
|
|
631
|
-
|
|
632
|
-
delete aggregate.bail;
|
|
633
|
-
return await write(aggregate);
|
|
505
|
+
if (aggregate.state !== "running") {
|
|
506
|
+
return refused("illegal-transition");
|
|
634
507
|
}
|
|
635
|
-
if (aggregate
|
|
636
|
-
|
|
637
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
|
699
|
-
if (aggregate?.
|
|
700
|
-
|
|
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
|