@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.
- package/README.md +1 -1
- package/SPECS.md +120 -94
- 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 +71 -0
- package/dist/ledger/feature-commands.d.ts.map +1 -0
- package/dist/ledger/feature-commands.js +272 -0
- package/dist/ledger/feature-commands.js.map +1 -0
- package/dist/ledger/open-work-ledger.d.ts +38 -55
- package/dist/ledger/open-work-ledger.d.ts.map +1 -1
- package/dist/ledger/open-work-ledger.js +228 -300
- 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 +122 -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,58 @@
|
|
|
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 };
|
|
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 (
|
|
32
|
-
intention.
|
|
39
|
+
if (ticket.line !== undefined) {
|
|
40
|
+
intention.line = ticket.line;
|
|
33
41
|
}
|
|
34
|
-
if (
|
|
35
|
-
intention.
|
|
42
|
+
if (ticket.no_tests === true) {
|
|
43
|
+
intention.no_tests = true;
|
|
36
44
|
}
|
|
37
|
-
if (
|
|
38
|
-
intention.
|
|
45
|
+
if (ticket.e2e === true) {
|
|
46
|
+
intention.e2e = true;
|
|
39
47
|
}
|
|
40
48
|
return intention;
|
|
41
49
|
}
|
|
42
|
-
|
|
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
|
|
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
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
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(
|
|
105
|
-
const existing = await
|
|
142
|
+
async admit(ticket) {
|
|
143
|
+
const existing = await loadTicket(ticket.key);
|
|
106
144
|
if (existing === undefined) {
|
|
107
145
|
const aggregate = {
|
|
108
|
-
intention: cloneIntention(
|
|
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 (
|
|
124
|
-
|
|
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
|
|
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 =
|
|
169
|
+
next.pending_fingerprint = ticket.fingerprint;
|
|
131
170
|
return await write(next);
|
|
132
171
|
}
|
|
133
|
-
if (existing.state === "received" && existing.intention.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(
|
|
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
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
return
|
|
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
|
|
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 !== "
|
|
250
|
+
if (aggregate.state !== "running") {
|
|
212
251
|
return refused("illegal-transition");
|
|
213
252
|
}
|
|
214
|
-
if (
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
...draft,
|
|
225
|
-
state: initialSubtaskState(draft),
|
|
226
|
-
})),
|
|
256
|
+
const attempt = {
|
|
257
|
+
number: input.number,
|
|
258
|
+
round: aggregate.resumes,
|
|
227
259
|
};
|
|
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");
|
|
260
|
+
if (input.trace !== undefined) {
|
|
261
|
+
attempt.trace = input.trace;
|
|
240
262
|
}
|
|
241
|
-
aggregate.
|
|
242
|
-
aggregate.
|
|
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
|
|
248
|
-
const current = await loadOrRefuse(
|
|
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.
|
|
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
|
|
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);
|
|
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"
|
|
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
|
-
|
|
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
|
|
296
|
+
* The assembled ticket is now in front of the Authority.
|
|
328
297
|
*
|
|
329
|
-
* A second call on an already submitted
|
|
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
|
|
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
|
|
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
|
|
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 === "
|
|
465
|
-
const
|
|
466
|
-
if (
|
|
467
|
-
|
|
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
|
|
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
|
|
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
|
|
511
|
-
aggregate.resumes
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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(
|
|
580
|
-
const current = await loadOrRefuse(
|
|
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.
|
|
539
|
+
if (aggregate.workspace === undefined) {
|
|
586
540
|
return { ok: true };
|
|
587
541
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
|
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
|
|
631
|
-
|
|
632
|
-
delete aggregate.bail;
|
|
633
|
-
return await write(aggregate);
|
|
578
|
+
if (aggregate.state !== "running") {
|
|
579
|
+
return refused("illegal-transition");
|
|
634
580
|
}
|
|
635
|
-
if (aggregate
|
|
636
|
-
|
|
637
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|
|
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
|
|
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);
|
|
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
|