@infinisys/is-fabric-team 0.1.0 → 0.2.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/lib/lanes.js ADDED
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Lanes: the shape of parallel work in the team ledger.
3
+ *
4
+ * A lane is one tracked requirement in flight. It owns its own stage, its own
5
+ * decision and its own evidence, so several asks can be worked on at once
6
+ * instead of queueing behind a single global cycle. What used to be the cycle's
7
+ * exclusivity lives in the two places that actually need it: one approved
8
+ * decision per lane, and a merge queue that lands branches on `master` one at a
9
+ * time.
10
+ *
11
+ * The ledger also keeps `stage`, `activeRequirementId` and `activeDecisionId`.
12
+ * They mirror the focused lane, so readers written against a single cycle (the
13
+ * status view, the watchdog, the panel) keep reporting something true.
14
+ */
15
+ import { stageName } from './status-view.js';
16
+ import { TeamError } from './team-error.js';
17
+ import { requireBounded, requireSeeded } from './ledger-guards.js';
18
+
19
+ /**
20
+ * The stage one lane is standing at.
21
+ *
22
+ * Every requirement owns its own pointer. The ledger-level `state.stage` stays
23
+ * as the mirror of the focused lane, and fills in for a requirement written
24
+ * before lanes existed.
25
+ *
26
+ * @param {object} state - ledger state.
27
+ * @param {object | undefined} requirement - lane record.
28
+ * @returns {string} stage id.
29
+ */
30
+ export function laneStage(state, requirement) {
31
+ if (requirement === undefined) return state.stage ?? 'intake';
32
+ if (typeof requirement.stage === 'string') return requirement.stage;
33
+ return requirement.id === state.activeRequirementId ? state.stage : 'intake';
34
+ }
35
+
36
+ /**
37
+ * Whether one requirement is still being worked on.
38
+ *
39
+ * Ledgers written before lanes existed carry no `status` on the requirement
40
+ * they were working; it reads as open rather than as a closed record.
41
+ *
42
+ * @param {object | undefined} requirement - lane record.
43
+ * @returns {boolean} true while the lane may still advance.
44
+ */
45
+ export function laneIsOpen(requirement) {
46
+ return requirement !== undefined && (requirement.status === undefined || requirement.status === 'open');
47
+ }
48
+
49
+ /**
50
+ * Every requirement still being worked on, in intake order.
51
+ *
52
+ * @param {object} state - ledger state.
53
+ * @returns {object[]} open lanes.
54
+ */
55
+ export function openLanes(state) {
56
+ return (state.requirements ?? []).filter((requirement) => laneIsOpen(requirement));
57
+ }
58
+
59
+ /**
60
+ * The lane the ledger reports on: its focus.
61
+ *
62
+ * @param {object} state - ledger state.
63
+ * @returns {object | undefined} focused lane.
64
+ */
65
+ export function focusedLane(state) {
66
+ return openLanes(state).find((requirement) => requirement.id === state.activeRequirementId);
67
+ }
68
+
69
+ /**
70
+ * Whether a lane still owns work in flight (a lane at retro has shipped).
71
+ *
72
+ * @param {object} state - ledger state.
73
+ * @param {object | undefined} requirement - lane record.
74
+ * @returns {boolean} true while the lane has stages ahead of it.
75
+ */
76
+ export function laneInFlight(state, requirement) {
77
+ return laneIsOpen(requirement) && laneStage(state, requirement) !== 'retro';
78
+ }
79
+
80
+ /**
81
+ * Point the ledger's reported state at one lane.
82
+ *
83
+ * Focus is presentation and defaulting, never ownership: every action that
84
+ * changes a lane takes its id explicitly, and this only decides which lane
85
+ * `status` leads with and which one an action without an id lands on.
86
+ *
87
+ * @param {object} state - ledger state.
88
+ * @param {string} requirementId - lane to focus.
89
+ * @returns {object} the focused requirement.
90
+ * @throws {TeamError} when the requirement does not exist or is closed.
91
+ */
92
+ export function focusLane(state, requirementId) {
93
+ const requirement = (state.requirements ?? []).find((candidate) => candidate.id === requirementId);
94
+ if (requirement === undefined) {
95
+ throw new TeamError(
96
+ `unknown requirement "${requirementId}"; there is no lane to focus`,
97
+ 'unknown-requirement',
98
+ );
99
+ }
100
+ if (!laneIsOpen(requirement)) {
101
+ throw new TeamError(
102
+ `requirement ${requirement.id} is ${requirement.status ?? 'closed'}; only an open lane takes the focus`,
103
+ 'lane-closed',
104
+ { status: 409 },
105
+ );
106
+ }
107
+ state.activeRequirementId = requirement.id;
108
+ state.activeDecisionId = requirement.decisionId ?? null;
109
+ state.stage = laneStage(state, requirement);
110
+ return requirement;
111
+ }
112
+
113
+ /**
114
+ * Focus the oldest lane that still has work, or report that nothing is running.
115
+ *
116
+ * @param {object} state - ledger state.
117
+ * @returns {object | undefined} the lane that took the focus.
118
+ */
119
+ export function focusOldestRunning(state) {
120
+ const next = openLanes(state).find((requirement) => laneInFlight(state, requirement));
121
+ if (next === undefined) {
122
+ state.activeRequirementId = null;
123
+ state.activeDecisionId = null;
124
+ state.stage = 'intake';
125
+ return undefined;
126
+ }
127
+ return focusLane(state, next.id);
128
+ }
129
+
130
+ /**
131
+ * Resolve the lane an action lands on.
132
+ *
133
+ * An explicit id always wins. Without one, the focused lane is the default, so
134
+ * a caller written against the single-cycle ledger keeps working; when no lane
135
+ * holds the focus the action is refused instead of landing on nothing.
136
+ *
137
+ * @param {object} state - ledger state.
138
+ * @param {string | undefined} requirementId - explicit lane id.
139
+ * @returns {object} lane record.
140
+ * @throws {TeamError} when the lane is missing or closed.
141
+ */
142
+ export function laneFor(state, requirementId) {
143
+ const requirement = requirementIn(state, requirementId);
144
+ if (!laneIsOpen(requirement)) {
145
+ throw new TeamError(
146
+ `requirement ${requirement.id} is ${requirement.status ?? 'closed'}; only an open lane advances`,
147
+ 'lane-closed',
148
+ { status: 409 },
149
+ );
150
+ }
151
+ return requirement;
152
+ }
153
+
154
+ /**
155
+ * The requirement a caller named, without judging whether it is still open.
156
+ *
157
+ * @param {object} state - ledger state.
158
+ * @param {string | undefined} requirementId - named lane, defaulting to the focus.
159
+ * @returns {object} requirement record.
160
+ */
161
+ export function requirementIn(state, requirementId) {
162
+ const wanted = requirementId ?? state.activeRequirementId;
163
+ if (wanted === null || wanted === undefined) {
164
+ throw new TeamError(
165
+ 'no lane is in focus; pass "requirement_id" (open one with intake first)',
166
+ 'no-active-requirement',
167
+ );
168
+ }
169
+ const requirement = (state.requirements ?? []).find((candidate) => candidate.id === wanted);
170
+ if (requirement === undefined) {
171
+ throw new TeamError(
172
+ `unknown requirement "${wanted}"; read "status" for the open lanes`,
173
+ 'unknown-requirement',
174
+ );
175
+ }
176
+ return requirement;
177
+ }
178
+
179
+ /**
180
+ * Record where one lane's work lives.
181
+ *
182
+ * A lane without a branch and a worktree is a lane every seat edits in the same
183
+ * directory, which is the serial work the lanes exist to replace. Binding both
184
+ * is what lets two lanes edit at once: the commit's branch and the command's
185
+ * directory then name the lane that produced them, so no seat has to claim work
186
+ * and no reader has to trust the claim.
187
+ *
188
+ * @param {object} state - ledger state.
189
+ * @param {{ requirementId?: string, branch?: string, worktree?: string, mr?: string, member: string, now: string }} input - binding.
190
+ * @returns {object} the lane row.
191
+ */
192
+ export function bindLane(state, input) {
193
+ requireSeeded(state);
194
+ const requirement = laneFor(state, input.requirementId);
195
+ if (input.branch !== undefined) {
196
+ requirement.branch = requireBounded(input.branch, 'branch', 200);
197
+ }
198
+ if (input.worktree !== undefined) {
199
+ requirement.worktree = requireBounded(input.worktree, 'worktree', 500);
200
+ }
201
+ if (input.mr !== undefined) {
202
+ requirement.mergeRequest = parseMergeRef(input.mr);
203
+ }
204
+ requirement.boundAt = input.now;
205
+ requirement.boundBy = input.member;
206
+ state.updatedAt = input.now;
207
+ return laneSummaries(state).find((lane) => lane.id === requirement.id);
208
+ }
209
+
210
+ /**
211
+ * One row per open lane, for the status view.
212
+ *
213
+ * Listing them is what makes parallel work visible: with one global stage a
214
+ * reader could not tell whether anything else was moving.
215
+ *
216
+ * @param {object} state - ledger state.
217
+ * @returns {object[]} lane rows.
218
+ */
219
+ export function laneSummaries(state) {
220
+ return openLanes(state).map((lane) => ({
221
+ id: lane.id,
222
+ title: lane.title,
223
+ stage: laneStage(state, lane),
224
+ stageName: stageName(laneStage(state, lane)),
225
+ decisionId: lane.decisionId ?? null,
226
+ branch: lane.branch ?? null,
227
+ worktree: lane.worktree ?? null,
228
+ mr: lane.mergeRequest?.ref ?? null,
229
+ openedAt: lane.createdAt ?? null,
230
+ reopens: lane.reopens ?? 0,
231
+ }));
232
+ }
233
+
234
+ /**
235
+ * Put one lane on the merge queue.
236
+ *
237
+ * The queue, not the stage, is what keeps `master` buildable: any number of
238
+ * lanes may reach delivery, and their branches land one at a time.
239
+ *
240
+ * @param {object} state - ledger state.
241
+ * @param {object} requirement - lane record.
242
+ * @param {string} now - ISO timestamp.
243
+ * @returns {void}
244
+ */
245
+ export function enqueueMerge(state, requirement, now) {
246
+ if (!Array.isArray(state.mergeQueue)) state.mergeQueue = [];
247
+ if (state.mergeQueue.some((entry) => entry.requirementId === requirement.id)) return;
248
+ state.mergeQueue.push({
249
+ requirementId: requirement.id,
250
+ branch: requirement.branch ?? null,
251
+ enqueuedAt: now,
252
+ });
253
+ }
254
+
255
+ /**
256
+ * Take one lane off the merge queue.
257
+ *
258
+ * @param {object} state - ledger state.
259
+ * @param {string} requirementId - lane to remove.
260
+ * @returns {void}
261
+ */
262
+ export function dropFromMergeQueue(state, requirementId) {
263
+ if (!Array.isArray(state.mergeQueue)) return;
264
+ state.mergeQueue = state.mergeQueue.filter((entry) => entry.requirementId !== requirementId);
265
+ }
266
+
267
+ /**
268
+ * Deliveries waiting for their turn on `master`.
269
+ *
270
+ * @param {object} state - ledger state.
271
+ * @returns {object[]} queue rows.
272
+ */
273
+ export function mergeQueueRows(state) {
274
+ return (state.mergeQueue ?? []).map((entry) => ({ ...entry }));
275
+ }
276
+
277
+ /**
278
+ * Fill in the lane fields a ledger written before lanes does not carry.
279
+ *
280
+ * Requirements recorded by the older plugin have no `status`, no `stage` and no
281
+ * `decisionId`; evidence rows have no `requirementId`; a requirement waiting
282
+ * behind a running cycle is marked `queued`. None of that is corruption, so it
283
+ * is read into the lane shape instead of refused: the old queue becomes lanes,
284
+ * and the evidence is attributed to the lane that was open when it was
285
+ * recorded. A ledger that already has the fields is left untouched.
286
+ *
287
+ * @param {object} value - validated ledger.
288
+ * @returns {void}
289
+ */
290
+ export function backfillLanes(value) {
291
+ if (!Array.isArray(value.mergeQueue)) value.mergeQueue = [];
292
+ for (const requirement of value.requirements) backfillRequirement(value, requirement);
293
+ if (value.activeRequirementId === null && openLanes(value).length > 0) focusOldestRunning(value);
294
+ for (const row of value.evidence) {
295
+ if (row.requirementId === undefined) row.requirementId = laneAt(value, row.at);
296
+ }
297
+ for (const requirement of openLanes(value)) {
298
+ if (laneStage(value, requirement) === 'delivery') {
299
+ enqueueMerge(value, requirement, value.updatedAt);
300
+ }
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Fill in one requirement's lane fields.
306
+ *
307
+ * @param {object} value - validated ledger.
308
+ * @param {object} requirement - requirement record.
309
+ * @returns {void}
310
+ */
311
+ function backfillRequirement(value, requirement) {
312
+ if (requirement.status === 'queued') {
313
+ requirement.status = 'open';
314
+ requirement.stage = 'intake';
315
+ delete requirement.queuedAt;
316
+ }
317
+ if (requirement.status === undefined) {
318
+ requirement.status = requirement.id === value.activeRequirementId ? 'open' : 'delivered';
319
+ }
320
+ if (requirement.stage === undefined) {
321
+ requirement.stage = requirement.id === value.activeRequirementId ? value.stage : 'intake';
322
+ }
323
+ if (requirement.decisionId === undefined) {
324
+ requirement.decisionId = newestDecisionId(value, requirement.id);
325
+ }
326
+ if (requirement.status === 'open' && requirement.stage === 'retro') {
327
+ requirement.status = 'delivered';
328
+ requirement.deliveredAt ??= value.updatedAt;
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Which lane was the open one at a moment in the past.
334
+ *
335
+ * Evidence written before lanes carries no requirement id. The lane open at the
336
+ * time is the newest requirement opened at or before that moment, which is what
337
+ * a single-cycle ledger always meant; the oldest requirement is the fallback for
338
+ * a row older than every intake record.
339
+ *
340
+ * @param {object} state - ledger state.
341
+ * @param {unknown} at - ISO timestamp of the row.
342
+ * @returns {string | null} requirement id.
343
+ */
344
+ function laneAt(state, at) {
345
+ const stamp = String(at ?? '');
346
+ const opened = (state.requirements ?? []).filter(
347
+ (requirement) => String(requirement.createdAt ?? '') <= stamp,
348
+ );
349
+ if (opened.length > 0) return opened.at(-1).id;
350
+ return (state.requirements ?? [])[0]?.id ?? null;
351
+ }
352
+
353
+ /**
354
+ * Newest decision recorded for one requirement.
355
+ *
356
+ * @param {object} state - ledger state.
357
+ * @param {string} requirementId - lane id.
358
+ * @returns {string | null} decision id.
359
+ */
360
+ function newestDecisionId(state, requirementId) {
361
+ const decisions = (state.decisions ?? []).filter(
362
+ (decision) => decision.requirementId === requirementId,
363
+ );
364
+ return decisions.length === 0 ? null : decisions.at(-1).id;
365
+ }
366
+
367
+ /**
368
+ * Read a merge-request reference written as `project#iid`.
369
+ *
370
+ * Binding the request, not just the branch, is what lets the merge gate check
371
+ * the lane it is about: a merge is asked of a project and an iid, and nothing
372
+ * in that call names the requirement.
373
+ *
374
+ * @param {string} value - merge-request reference.
375
+ * @returns {{ project: string, iid: number, ref: string }} parsed reference.
376
+ * @throws {TeamError} when the text is not `project#iid`.
377
+ */
378
+ function parseMergeRef(value) {
379
+ const text = requireBounded(value, 'mr', 200);
380
+ const hash = text.lastIndexOf('#');
381
+ const project = hash === -1 ? '' : text.slice(0, hash).trim();
382
+ const iid = hash === -1 ? NaN : Number(text.slice(hash + 1).trim());
383
+ if (project === '' || !Number.isInteger(iid) || iid < 1) {
384
+ throw new TeamError(
385
+ '"mr" must name the merge request as "project#iid", for example "team/app#42"',
386
+ 'invalid-arguments',
387
+ { status: 400 },
388
+ );
389
+ }
390
+ return { project, iid, ref: `${project}#${iid}` };
391
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Guards every lane mutation runs first: the ledger is seeded, and the text a
3
+ * caller hands in is trimmed to a bound rather than stored raw.
4
+ */
5
+ import { TeamError } from './team-error.js';
6
+
7
+ /**
8
+ * Trim an optional string and enforce its bound.
9
+ *
10
+ * @param {unknown} value - candidate text.
11
+ * @param {string} field - field name for the error.
12
+ * @param {number} max - maximum accepted characters.
13
+ * @returns {string | undefined} trimmed text, or undefined when empty.
14
+ */
15
+ export function optionalBounded(value, field, max) {
16
+ if (value === undefined || value === null) return undefined;
17
+ if (typeof value !== 'string') {
18
+ throw new TeamError(`"${field}" must be a string`, 'invalid-arguments', { status: 400 });
19
+ }
20
+ const text = value.trim();
21
+ if (text === '') return undefined;
22
+ if (text.length > max) {
23
+ throw new TeamError(`"${field}" exceeds ${max} characters`, 'invalid-arguments', { status: 400 });
24
+ }
25
+ return text;
26
+ }
27
+
28
+ /**
29
+ * Trim a required string and enforce its bound.
30
+ *
31
+ * @param {unknown} value - candidate text.
32
+ * @param {string} field - field name for the error.
33
+ * @param {number} max - maximum accepted characters.
34
+ * @returns {string} trimmed text.
35
+ */
36
+ export function requireBounded(value, field, max) {
37
+ const text = optionalBounded(value, field, max);
38
+ if (text === undefined) {
39
+ throw new TeamError(`"${field}" is required`, 'invalid-arguments', { status: 400 });
40
+ }
41
+ return text;
42
+ }
43
+
44
+ /**
45
+ * Fail unless the team has been seeded with a GitFox project.
46
+ *
47
+ * @param {object} state - ledger state.
48
+ * @returns {void}
49
+ */
50
+ export function requireSeeded(state) {
51
+ if (state.seeded !== true) {
52
+ throw new TeamError(
53
+ 'The team is not seeded. The Lead must call infinity_team action "bootstrap" with the GitFox project first.',
54
+ 'not-seeded',
55
+ );
56
+ }
57
+ }
package/lib/open-work.js CHANGED
@@ -20,7 +20,7 @@
20
20
  * as stale instead of open.
21
21
  */
22
22
  import { problemStatus } from './problem-gate.js';
23
- import { activeDecision } from './team-core.js';
23
+ import { decisionOf, laneStage, openLanes } from './team-core.js';
24
24
 
25
25
  /** How long a pending review stays actionable after it was opened. */
26
26
  export const REVIEW_TTL_MS = 24 * 60 * 60 * 1000;
@@ -43,16 +43,30 @@ export function openWork(state, options = {}) {
43
43
  if (state === null || typeof state !== 'object' || state.seeded !== true) return [];
44
44
  const now = timestamp(options.now, Date.now());
45
45
  const reviewTtlMs = options.reviewTtlMs ?? REVIEW_TTL_MS;
46
- const problem = problemStatus(state);
47
- const requirement = activeRequirement(state, problem);
48
46
  /** @type {OpenItem[]} */
49
47
  const items = [];
50
- if (requirement !== undefined && !problem.resolved) {
51
- items.push(requirementItem(requirement, problem));
48
+ for (const requirement of openLanes(state)) {
49
+ items.push(...laneWork(state, requirement, now, reviewTtlMs));
52
50
  }
53
- const decision = decisionItem(activeDecision(state));
51
+ return items;
52
+ }
53
+
54
+ /**
55
+ * The open work of one lane, in the order it has to be taken.
56
+ *
57
+ * @param {object} state - team ledger.
58
+ * @param {object} requirement - one open lane.
59
+ * @param {number} now - current epoch milliseconds.
60
+ * @param {number} ttl - review lifetime.
61
+ * @returns {OpenItem[]} items owed by that lane.
62
+ */
63
+ function laneWork(state, requirement, now, ttl) {
64
+ const problem = problemStatus(state, requirement);
65
+ const items = [];
66
+ if (!problem.resolved) items.push(requirementItem(requirement, problem));
67
+ const decision = decisionItem(decisionOf(state, requirement));
54
68
  if (decision !== undefined) items.push(decision);
55
- items.push(...reviewItems(state, requirement, now, reviewTtlMs));
69
+ items.push(...reviewItems(state, requirement, now, ttl));
56
70
  const workflow = workflowItem(state, requirement, problem);
57
71
  if (workflow !== undefined) items.push(workflow);
58
72
  return items;
@@ -90,13 +104,12 @@ function reviewItems(state, requirement, now, ttl) {
90
104
  * @returns {OpenItem | undefined} workflow item.
91
105
  */
92
106
  function workflowItem(state, requirement, problem) {
93
- if (requirement === undefined || !problem.resolved || state.stage === 'retro') {
94
- return undefined;
95
- }
107
+ const stage = laneStage(state, requirement);
108
+ if (!problem.resolved || stage === 'retro') return undefined;
96
109
  return {
97
110
  kind: 'workflow',
98
- ref: state.stage,
99
- text: `the cycle is still at "${state.stage}" — its checks are green, so delivery and retro remain`,
111
+ ref: requirement.id,
112
+ text: `${requirement.id} is still at "${stage}" — its checks are green, so delivery and retro remain`,
100
113
  };
101
114
  }
102
115
 
@@ -116,26 +129,13 @@ export function staleReviews(state, options = {}) {
116
129
  if (state === null || typeof state !== 'object' || state.seeded !== true) return [];
117
130
  const now = timestamp(options.now, Date.now());
118
131
  const reviewTtlMs = options.reviewTtlMs ?? REVIEW_TTL_MS;
119
- const problem = problemStatus(state);
120
- const requirement = activeRequirement(state, problem);
132
+ const lanes = openLanes(state);
121
133
  return (state.reviews ?? []).filter((review) => {
122
134
  if (review.status !== 'pending') return false;
123
- if (requirement === undefined) return true;
124
- return !reviewBelongsToCycle(review, requirement, now, reviewTtlMs);
135
+ return !lanes.some((lane) => reviewBelongsToCycle(review, lane, now, reviewTtlMs));
125
136
  });
126
137
  }
127
138
 
128
- /**
129
- * The requirement the active cycle belongs to, if there is one.
130
- *
131
- * @param {object} state - ledger state.
132
- * @param {ReturnType<typeof problemStatus>} problem - gate view over it.
133
- * @returns {object | undefined} requirement record.
134
- */
135
- function activeRequirement(state, problem) {
136
- return (state.requirements ?? []).find((candidate) => candidate.id === problem.requirementId);
137
- }
138
-
139
139
  /**
140
140
  * Whether one pending review still belongs to the active cycle.
141
141
  *