@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/README.md +22 -7
- package/client.js +3 -3
- package/cordis.patch.yml +5 -1
- package/index.js +29 -1
- package/lib/criterion-amend.js +23 -7
- package/lib/cycle-close.js +7 -63
- package/lib/lane-action.js +106 -0
- package/lib/lanes.js +391 -0
- package/lib/ledger-guards.js +57 -0
- package/lib/open-work.js +27 -27
- package/lib/problem-gate.js +179 -58
- package/lib/problem-host.js +97 -3
- package/lib/publish-action.js +8 -5
- package/lib/session-dir.js +21 -0
- package/lib/team-core.js +155 -155
- package/lib/team-model.js +4 -4
- package/lib/tool-schema.js +18 -2
- package/lib/verdict-pool.mjs +177 -0
- package/lib/verdict-worker.mjs +35 -0
- package/lib/watchdog.js +3 -0
- package/lib/work-record-gate.js +2 -2
- package/package.json +3 -2
package/lib/problem-gate.js
CHANGED
|
@@ -253,14 +253,45 @@ function evidenceOf(state) {
|
|
|
253
253
|
return Array.isArray(state?.evidence) ? state.evidence : [];
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
/**
|
|
257
|
+
* The requirement one evidence row belongs to.
|
|
258
|
+
*
|
|
259
|
+
* Attribution is written when the row is observed, not guessed when it is read:
|
|
260
|
+
* a commit knows its branch and a command knows the directory it ran in, and the
|
|
261
|
+
* Host stamps both with the lane it resolved. Reading it back as an id is what
|
|
262
|
+
* keeps two lanes' runs apart — matching by command text alone let one lane's
|
|
263
|
+
* check green a criterion in another.
|
|
264
|
+
*
|
|
265
|
+
* @param {object} row - evidence row.
|
|
266
|
+
* @returns {string | null} requirement id, or null for evidence recorded while
|
|
267
|
+
* no lane was open.
|
|
268
|
+
*/
|
|
269
|
+
export function requirementOfEvidence(row) {
|
|
270
|
+
return typeof row?.requirementId === 'string' ? row.requirementId : null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The evidence rows that belong to one lane.
|
|
275
|
+
*
|
|
276
|
+
* @param {object} state - ledger state.
|
|
277
|
+
* @param {object | undefined} requirement - lane; defaults to the focused one.
|
|
278
|
+
* @returns {object[]} rows of that lane.
|
|
279
|
+
*/
|
|
280
|
+
function scopedRows(state, requirement) {
|
|
281
|
+
const id = requirement?.id ?? state?.activeRequirementId ?? null;
|
|
282
|
+
const rows = evidenceOf(state);
|
|
283
|
+
if (id === null) return rows;
|
|
284
|
+
return rows.filter((row) => requirementOfEvidence(row) === id);
|
|
285
|
+
}
|
|
286
|
+
|
|
256
287
|
/**
|
|
257
288
|
* The position of the newest observed commit, or -1 when none exists.
|
|
258
289
|
*
|
|
259
290
|
* @param {object} state - ledger state.
|
|
260
291
|
* @returns {number} evidence array index.
|
|
261
292
|
*/
|
|
262
|
-
function newestCommitIndex(state) {
|
|
263
|
-
const rows =
|
|
293
|
+
function newestCommitIndex(state, requirement) {
|
|
294
|
+
const rows = scopedRows(state, requirement);
|
|
264
295
|
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
|
265
296
|
if (rows[index]?.kind === 'commit') return index;
|
|
266
297
|
}
|
|
@@ -303,10 +334,11 @@ export function runVerdict(command, check) {
|
|
|
303
334
|
*
|
|
304
335
|
* @param {object} state - ledger state.
|
|
305
336
|
* @param {{ check?: string }} criterion - acceptance criterion.
|
|
337
|
+
* @param {object} [requirement] - lane whose run must count; defaults to the focused one.
|
|
306
338
|
* @returns {object[]} matching command evidence.
|
|
307
339
|
*/
|
|
308
|
-
export function runsOf(state, criterion) {
|
|
309
|
-
return runsOfVerdict(state, criterion, 'attributable');
|
|
340
|
+
export function runsOf(state, criterion, requirement) {
|
|
341
|
+
return runsOfVerdict(state, criterion, 'attributable', requirement);
|
|
310
342
|
}
|
|
311
343
|
|
|
312
344
|
/**
|
|
@@ -318,22 +350,24 @@ export function runsOf(state, criterion) {
|
|
|
318
350
|
*
|
|
319
351
|
* @param {object} state - ledger state.
|
|
320
352
|
* @param {{ check?: string }} criterion - acceptance criterion.
|
|
353
|
+
* @param {object} [requirement] - lane whose run must count; defaults to the focused one.
|
|
321
354
|
* @returns {object[]} matching command evidence.
|
|
322
355
|
*/
|
|
323
|
-
export function maskedRunsOf(state, criterion) {
|
|
324
|
-
return runsOfVerdict(state, criterion, 'masked');
|
|
356
|
+
export function maskedRunsOf(state, criterion, requirement) {
|
|
357
|
+
return runsOfVerdict(state, criterion, 'masked', requirement);
|
|
325
358
|
}
|
|
326
359
|
|
|
327
360
|
/**
|
|
328
361
|
* @param {object} state - ledger state.
|
|
329
362
|
* @param {{ check?: string }} criterion - acceptance criterion.
|
|
330
363
|
* @param {'attributable' | 'masked'} wanted - verdict to keep.
|
|
364
|
+
* @param {object} [requirement] - lane whose runs count.
|
|
331
365
|
* @returns {object[]} matching command evidence.
|
|
332
366
|
*/
|
|
333
|
-
function runsOfVerdict(state, criterion, wanted) {
|
|
367
|
+
function runsOfVerdict(state, criterion, wanted, requirement) {
|
|
334
368
|
const check = normalizeCommand(criterion?.check);
|
|
335
369
|
if (check === '') return [];
|
|
336
|
-
const rows =
|
|
370
|
+
const rows = scopedRows(state, requirement).filter((row) => row.kind === 'command');
|
|
337
371
|
const verdicts = commandVerdicts(rows, check);
|
|
338
372
|
return rows.filter((row, index) => verdicts[index] === wanted);
|
|
339
373
|
}
|
|
@@ -398,12 +432,13 @@ function commandVerdicts(rows, check) {
|
|
|
398
432
|
*
|
|
399
433
|
* @param {object} state - ledger state.
|
|
400
434
|
* @param {{ check?: string }} criterion - acceptance criterion.
|
|
435
|
+
* @param {object} [requirement] - lane whose evidence decides this criterion.
|
|
401
436
|
* @returns {{ passed: boolean, reason?: string, exitCode?: number, workaround?: boolean, neverFailed?: boolean, staleAgainst?: CommitFacts | null, masked?: string }} status.
|
|
402
437
|
*/
|
|
403
|
-
export function criterionStatus(state, criterion) {
|
|
404
|
-
const runs = runsOf(state, criterion);
|
|
438
|
+
export function criterionStatus(state, criterion, requirement) {
|
|
439
|
+
const runs = runsOf(state, criterion, requirement);
|
|
405
440
|
if (runs.length === 0) {
|
|
406
|
-
const masked = maskedRunsOf(state, criterion);
|
|
441
|
+
const masked = maskedRunsOf(state, criterion, requirement);
|
|
407
442
|
if (masked.length === 0) return { passed: false, reason: 'never-run' };
|
|
408
443
|
return { passed: false, reason: 'masked-run', masked: String(masked.at(-1).detail ?? '') };
|
|
409
444
|
}
|
|
@@ -413,10 +448,9 @@ export function criterionStatus(state, criterion) {
|
|
|
413
448
|
}
|
|
414
449
|
// Whether a commit landed after this run is a fact about the tree, not about
|
|
415
450
|
// the check: it travels beside the pass so the reader can judge how old the
|
|
416
|
-
// evidence is, and never turns a green run into a refusal.
|
|
417
|
-
|
|
418
|
-
const
|
|
419
|
-
const stale = commitIndex !== -1 && commitIndex > rows.indexOf(latest);
|
|
451
|
+
// evidence is, and never turns a green run into a refusal. Both sides are
|
|
452
|
+
// read inside the lane, so another lane's commit never ages this pass.
|
|
453
|
+
const staleAgainst = isLocalOnly(state) ? null : commitAfter(state, requirement, latest);
|
|
420
454
|
// A failing run says the check exercises the problem — but it does not matter
|
|
421
455
|
// *when* it ran, and it is not required at all. Requiring it asked the team to
|
|
422
456
|
// manufacture the order (commit the broken state, run, then commit the fix),
|
|
@@ -428,10 +462,31 @@ export function criterionStatus(state, criterion) {
|
|
|
428
462
|
passed: true,
|
|
429
463
|
workaround: latest.workaround === true,
|
|
430
464
|
neverFailed: !failed,
|
|
431
|
-
...(
|
|
465
|
+
...(staleAgainst === null ? {} : { staleAgainst }),
|
|
432
466
|
};
|
|
433
467
|
}
|
|
434
468
|
|
|
469
|
+
/**
|
|
470
|
+
* The newest commit on one lane when it was observed after a given row.
|
|
471
|
+
*
|
|
472
|
+
* Ordering is the order the Host recorded the rows in, not the clock: a commit
|
|
473
|
+
* and the run it followed can carry the same second, and a run recorded before
|
|
474
|
+
* a commit stays older than that commit. Both rows are read inside the lane, so
|
|
475
|
+
* another lane's commit never ages this pass.
|
|
476
|
+
*
|
|
477
|
+
* @param {object} state - ledger state.
|
|
478
|
+
* @param {object} requirement - lane to read.
|
|
479
|
+
* @param {object} row - the run being judged.
|
|
480
|
+
* @returns {CommitFacts | null} the newer commit, or null.
|
|
481
|
+
*/
|
|
482
|
+
function commitAfter(state, requirement, row) {
|
|
483
|
+
const index = newestCommitIndex(state, requirement);
|
|
484
|
+
if (index === -1) return null;
|
|
485
|
+
const newestRow = scopedRows(state, requirement)[index];
|
|
486
|
+
const all = evidenceOf(state);
|
|
487
|
+
return all.indexOf(newestRow) > all.indexOf(row) ? commitFacts(newestRow) : null;
|
|
488
|
+
}
|
|
489
|
+
|
|
435
490
|
/**
|
|
436
491
|
* Short branch name for one evidence row, or null when the Host did not record
|
|
437
492
|
* one (evidence written before branches were captured).
|
|
@@ -476,9 +531,9 @@ function commitFacts(row) {
|
|
|
476
531
|
* @param {object} state - ledger state.
|
|
477
532
|
* @returns {CommitFacts | null} newest commit, or null when none was observed.
|
|
478
533
|
*/
|
|
479
|
-
export function newestCommit(state) {
|
|
480
|
-
const index = newestCommitIndex(state);
|
|
481
|
-
return index === -1 ? null : commitFacts(
|
|
534
|
+
export function newestCommit(state, requirement) {
|
|
535
|
+
const index = newestCommitIndex(state, requirement);
|
|
536
|
+
return index === -1 ? null : commitFacts(scopedRows(state, requirement)[index]);
|
|
482
537
|
}
|
|
483
538
|
|
|
484
539
|
/**
|
|
@@ -512,7 +567,7 @@ export function describeCommit(facts) {
|
|
|
512
567
|
*/
|
|
513
568
|
export function productDelta(state, requirement) {
|
|
514
569
|
const since = typeof requirement?.createdAt === 'string' ? requirement.createdAt : '';
|
|
515
|
-
const commits =
|
|
570
|
+
const commits = scopedRows(state, requirement).filter(
|
|
516
571
|
(row) => row?.kind === 'commit' && String(row.at ?? '') >= since,
|
|
517
572
|
);
|
|
518
573
|
const mainline = commits.filter((row) => MAINLINE_BRANCHES.includes(branchName(row)));
|
|
@@ -541,12 +596,10 @@ export function productDelta(state, requirement) {
|
|
|
541
596
|
* question about the instrument.
|
|
542
597
|
*
|
|
543
598
|
* @param {object} state - ledger state.
|
|
599
|
+
* @param {object} [requirement] - lane to report; defaults to the focused one.
|
|
544
600
|
* @returns {{ resolved: boolean, requirementId: string | null, total: number, passed: number, workarounds: number[], neverFailed: number[], missing: object[], delta: ProductDelta, newest: CommitFacts | null, checks: { total: number, ran: number, neverRan: number, masked: number } }} status.
|
|
545
601
|
*/
|
|
546
|
-
export function problemStatus(state) {
|
|
547
|
-
const requirement = (state.requirements ?? []).find(
|
|
548
|
-
(candidate) => candidate.id === state.activeRequirementId,
|
|
549
|
-
);
|
|
602
|
+
export function problemStatus(state, requirement = activeRequirement(state)) {
|
|
550
603
|
if (requirement === undefined) {
|
|
551
604
|
return {
|
|
552
605
|
resolved: false,
|
|
@@ -569,16 +622,18 @@ export function problemStatus(state) {
|
|
|
569
622
|
}
|
|
570
623
|
const rows = criteriaOf(requirement).map((criterion) => ({
|
|
571
624
|
...criterion,
|
|
572
|
-
...criterionStatus(state, criterion),
|
|
625
|
+
...criterionStatus(state, criterion, requirement),
|
|
573
626
|
}));
|
|
574
627
|
const missing = rows.filter((row) => row.passed !== true);
|
|
575
628
|
// Three states, disjoint by construction: a check whose run carried its own
|
|
576
629
|
// exit code, a check that ran but lost the exit code to a later command, and
|
|
577
630
|
// a check never invoked at all. Calling the middle one "never ran" is what
|
|
578
631
|
// told the team its finished work had not been attempted.
|
|
579
|
-
const ran = rows.filter((row) => runsOf(state, row).length > 0).length;
|
|
632
|
+
const ran = rows.filter((row) => runsOf(state, row, requirement).length > 0).length;
|
|
580
633
|
const masked = rows.filter(
|
|
581
|
-
(row) =>
|
|
634
|
+
(row) =>
|
|
635
|
+
runsOf(state, row, requirement).length === 0 &&
|
|
636
|
+
maskedRunsOf(state, row, requirement).length > 0,
|
|
582
637
|
).length;
|
|
583
638
|
return {
|
|
584
639
|
resolved: rows.length > 0 && missing.length === 0,
|
|
@@ -589,7 +644,7 @@ export function problemStatus(state) {
|
|
|
589
644
|
neverFailed: rows.filter((row) => row.neverFailed === true).map((row) => row.index),
|
|
590
645
|
missing,
|
|
591
646
|
delta: productDelta(state, requirement),
|
|
592
|
-
newest: newestCommit(state),
|
|
647
|
+
newest: newestCommit(state, requirement),
|
|
593
648
|
checks: { total: rows.length, ran, masked, neverRan: rows.length - ran - masked },
|
|
594
649
|
};
|
|
595
650
|
}
|
|
@@ -613,10 +668,10 @@ export function problemStatus(state) {
|
|
|
613
668
|
* With no active requirement nothing is in flight, so nothing is gated.
|
|
614
669
|
*
|
|
615
670
|
* @param {object} state - ledger state.
|
|
671
|
+
* @param {object} [requirement] - lane whose criteria are being closed over.
|
|
616
672
|
* @returns {{ gated: boolean, resolved: boolean, requirementId: string | null, total: number, ran: number, passed: number, failed: object[], missing: object[], newest: CommitFacts | null }} status.
|
|
617
673
|
*/
|
|
618
|
-
export function closureStatus(state) {
|
|
619
|
-
const requirement = activeRequirement(state);
|
|
674
|
+
export function closureStatus(state, requirement = activeRequirement(state)) {
|
|
620
675
|
if (requirement === undefined) {
|
|
621
676
|
return {
|
|
622
677
|
gated: false,
|
|
@@ -627,7 +682,7 @@ export function closureStatus(state) {
|
|
|
627
682
|
passed: 0,
|
|
628
683
|
failed: [],
|
|
629
684
|
missing: [],
|
|
630
|
-
newest: newestCommit(state),
|
|
685
|
+
newest: newestCommit(state, requirement),
|
|
631
686
|
};
|
|
632
687
|
}
|
|
633
688
|
const criteria = criteriaOf(requirement).filter(
|
|
@@ -635,7 +690,7 @@ export function closureStatus(state) {
|
|
|
635
690
|
);
|
|
636
691
|
const rows = criteria.map((criterion) => ({
|
|
637
692
|
...criterion,
|
|
638
|
-
...criterionStatus(state, criterion),
|
|
693
|
+
...criterionStatus(state, criterion, requirement),
|
|
639
694
|
}));
|
|
640
695
|
const missing = rows.filter((row) => row.passed !== true);
|
|
641
696
|
const failed = rows.filter((row) => row.reason === 'failed');
|
|
@@ -650,34 +705,92 @@ export function closureStatus(state) {
|
|
|
650
705
|
passed: rows.length - missing.length,
|
|
651
706
|
failed,
|
|
652
707
|
missing,
|
|
653
|
-
newest: newestCommit(state),
|
|
708
|
+
newest: newestCommit(state, requirement),
|
|
654
709
|
};
|
|
655
710
|
}
|
|
656
711
|
|
|
657
712
|
/**
|
|
658
|
-
*
|
|
659
|
-
* requirement.
|
|
713
|
+
* The open lane whose ticket a closing action targets.
|
|
660
714
|
*
|
|
661
|
-
* Only
|
|
662
|
-
*
|
|
663
|
-
*
|
|
715
|
+
* Only a lane's own ticket stands for the problem that lane claims to solve, so
|
|
716
|
+
* the bar is read from that lane rather than from whatever happens to hold the
|
|
717
|
+
* focus. Meeting tickets, decision tickets and tickets from other work are
|
|
718
|
+
* discussion bookkeeping: the gate must not hold them open until a lane ends.
|
|
719
|
+
*
|
|
720
|
+
* @param {object} state - ledger state.
|
|
721
|
+
* @param {{ verb: string, project: string, iid?: number }} target - artifact action.
|
|
722
|
+
* @returns {object | undefined} the lane that owns the ticket.
|
|
723
|
+
*/
|
|
724
|
+
export function laneOwningIssue(state, target) {
|
|
725
|
+
if (target?.verb !== 'close' || typeof target.project !== 'string') return undefined;
|
|
726
|
+
const iid = Number(target.iid);
|
|
727
|
+
if (!Number.isInteger(iid)) return undefined;
|
|
728
|
+
const wanted = target.project.trim().toLowerCase();
|
|
729
|
+
return (state.requirements ?? []).find((requirement) => {
|
|
730
|
+
if (requirement.status !== undefined && requirement.status !== 'open') return false;
|
|
731
|
+
const tracking = requirement.tracking;
|
|
732
|
+
if (tracking === null || tracking === undefined) return false;
|
|
733
|
+
if (typeof tracking.project !== 'string' || !Number.isInteger(tracking.issue)) return false;
|
|
734
|
+
return tracking.issue === iid && tracking.project.trim().toLowerCase() === wanted;
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* The open lane whose merge request a merging action targets.
|
|
740
|
+
*
|
|
741
|
+
* A merge names a project and an iid, never a requirement, so the lane has to
|
|
742
|
+
* be bound to its request (`action "lane"` with `mr`). Without a binding the
|
|
743
|
+
* gate leaves the merge alone rather than checking the focused lane: holding an
|
|
744
|
+
* unrelated request to another lane's bar is worse than not gating it.
|
|
745
|
+
*
|
|
746
|
+
* @param {object} state - ledger state.
|
|
747
|
+
* @param {{ verb: string, project: string, iid?: number }} target - artifact action.
|
|
748
|
+
* @returns {object | undefined} the lane that owns the request.
|
|
749
|
+
*/
|
|
750
|
+
export function laneOwningMerge(state, target) {
|
|
751
|
+
if (target?.verb !== 'merge' || typeof target.project !== 'string') return undefined;
|
|
752
|
+
const iid = Number(target.iid);
|
|
753
|
+
if (!Number.isInteger(iid)) return undefined;
|
|
754
|
+
const wanted = target.project.trim().toLowerCase();
|
|
755
|
+
return (state.requirements ?? []).find((requirement) => {
|
|
756
|
+
if (requirement.status !== undefined && requirement.status !== 'open') return false;
|
|
757
|
+
const request = requirement.mergeRequest;
|
|
758
|
+
if (request === undefined || request === null) return false;
|
|
759
|
+
return request.iid === iid && String(request.project).trim().toLowerCase() === wanted;
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Why a merge must wait for the queue.
|
|
765
|
+
*
|
|
766
|
+
* Deliveries land one at a time so `master` builds at every step. A request
|
|
767
|
+
* that is not in the queue is none of this rule's business (a hotfix, a release
|
|
768
|
+
* branch); one that is queued but not at the head waits, and the refusal names
|
|
769
|
+
* who goes first.
|
|
770
|
+
*
|
|
771
|
+
* @param {object} state - ledger state.
|
|
772
|
+
* @param {object | undefined} lane - the lane the request belongs to.
|
|
773
|
+
* @param {{ ref: string }} target - merge action.
|
|
774
|
+
* @returns {string | undefined} refusal reason.
|
|
775
|
+
*/
|
|
776
|
+
export function mergeQueueRefusal(state, lane, target) {
|
|
777
|
+
if (lane === undefined) return undefined;
|
|
778
|
+
const queue = Array.isArray(state.mergeQueue) ? state.mergeQueue : [];
|
|
779
|
+
const position = queue.findIndex((row) => row.requirementId === lane.id);
|
|
780
|
+
if (position <= 0) return undefined;
|
|
781
|
+
const head = queue[0].requirementId;
|
|
782
|
+
return `merge-queued: ${target.ref} belongs to ${lane.id}, and ${head} is ahead of it in the merge queue. Deliveries land one at a time so master builds at every step: wait for ${head} to land, then merge this one.`;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Whether a closing target is a ticket the ledger opened for an open lane.
|
|
664
787
|
*
|
|
665
788
|
* @param {object} state - ledger state.
|
|
666
789
|
* @param {{ verb: string, project: string, iid?: number }} target - artifact action.
|
|
667
790
|
* @returns {boolean} true when the ledger owns the ticket.
|
|
668
791
|
*/
|
|
669
792
|
export function ownsActiveIssue(state, target) {
|
|
670
|
-
|
|
671
|
-
const tracking = activeRequirement(state)?.tracking;
|
|
672
|
-
if (tracking === null || tracking === undefined) return false;
|
|
673
|
-
if (typeof tracking.project !== 'string' || !Number.isInteger(tracking.issue)) return false;
|
|
674
|
-
if (typeof target.project !== 'string') return false;
|
|
675
|
-
const iid = Number(target.iid);
|
|
676
|
-
return (
|
|
677
|
-
Number.isInteger(iid) &&
|
|
678
|
-
iid === tracking.issue &&
|
|
679
|
-
tracking.project.trim().toLowerCase() === target.project.trim().toLowerCase()
|
|
680
|
-
);
|
|
793
|
+
return laneOwningIssue(state, target) !== undefined;
|
|
681
794
|
}
|
|
682
795
|
|
|
683
796
|
/**
|
|
@@ -705,11 +818,12 @@ function missingText(row) {
|
|
|
705
818
|
*
|
|
706
819
|
* @param {object} state - ledger state.
|
|
707
820
|
* @param {string} action - blocked action name for the message.
|
|
821
|
+
* @param {object} [requirement] - lane making the claim.
|
|
708
822
|
* @returns {void}
|
|
709
823
|
* @throws {TeamError} when no criterion has been attempted.
|
|
710
824
|
*/
|
|
711
|
-
export function assertProblemResolved(state, action) {
|
|
712
|
-
const status = closureStatus(state);
|
|
825
|
+
export function assertProblemResolved(state, action, requirement) {
|
|
826
|
+
const status = closureStatus(state, requirement);
|
|
713
827
|
if (status.resolved) return;
|
|
714
828
|
const unobserved = status.missing.filter(
|
|
715
829
|
(row) => row.reason === 'never-run' || row.reason === 'masked-run',
|
|
@@ -732,17 +846,18 @@ export function assertProblemResolved(state, action) {
|
|
|
732
846
|
*
|
|
733
847
|
* @param {object} state - ledger state.
|
|
734
848
|
* @param {string} target - target stage.
|
|
849
|
+
* @param {object} [requirement] - lane being advanced.
|
|
735
850
|
* @returns {void}
|
|
736
851
|
* @throws {TeamError} when the observed work does not support the stage.
|
|
737
852
|
*/
|
|
738
|
-
export function assertStageProof(state, target) {
|
|
853
|
+
export function assertStageProof(state, target, requirement = activeRequirement(state)) {
|
|
739
854
|
if (!STAGES_USING_PROOF.includes(target)) return;
|
|
740
855
|
if (target === 'implementation') {
|
|
741
856
|
// A local-only workspace has no remote to publish to; the commit bar would
|
|
742
857
|
// only make the team commit for the gate. The approved decision, enforced
|
|
743
858
|
// by the caller, is the whole bar there.
|
|
744
859
|
if (isLocalOnly(state)) return;
|
|
745
|
-
if (newestCommitIndex(state) === -1) {
|
|
860
|
+
if (newestCommitIndex(state, requirement) === -1) {
|
|
746
861
|
throw new TeamError(
|
|
747
862
|
'stage "implementation" requires a commit recorded by infinity_git; the gate reads the commit, not a ticket or an evidence id',
|
|
748
863
|
'gate-unsatisfied',
|
|
@@ -751,14 +866,14 @@ export function assertStageProof(state, target) {
|
|
|
751
866
|
return;
|
|
752
867
|
}
|
|
753
868
|
if (target === 'test') {
|
|
754
|
-
const missing = criteriaOf(
|
|
755
|
-
(criterion) => runsOf(state, criterion).length === 0,
|
|
869
|
+
const missing = criteriaOf(requirement).filter(
|
|
870
|
+
(criterion) => runsOf(state, criterion, requirement).length === 0,
|
|
756
871
|
);
|
|
757
872
|
if (missing.length > 0) {
|
|
758
873
|
throw new TeamError(
|
|
759
874
|
`stage "test" requires every check to have run at least once, as the last command on its line. Not attributable yet: ${missing
|
|
760
875
|
.map((criterion) => {
|
|
761
|
-
const masked = maskedRunsOf(state, criterion).length > 0;
|
|
876
|
+
const masked = maskedRunsOf(state, criterion, requirement).length > 0;
|
|
762
877
|
return `#${criterion.index} ${criterion.check ?? '(no check)'}${masked ? ' (ran, but a later command in the same line took its exit code)' : ''}`;
|
|
763
878
|
})
|
|
764
879
|
.join('; ')}`,
|
|
@@ -767,7 +882,7 @@ export function assertStageProof(state, target) {
|
|
|
767
882
|
}
|
|
768
883
|
return;
|
|
769
884
|
}
|
|
770
|
-
assertProblemResolved(state, target);
|
|
885
|
+
assertProblemResolved(state, target, requirement);
|
|
771
886
|
}
|
|
772
887
|
|
|
773
888
|
/**
|
|
@@ -788,7 +903,12 @@ export function activeRequirement(state) {
|
|
|
788
903
|
* published?" from facts rather than from prose.
|
|
789
904
|
*
|
|
790
905
|
* @param {object} state - ledger state.
|
|
791
|
-
*
|
|
906
|
+
* Every row is stamped with the lane it was observed in. That stamp is what
|
|
907
|
+
* keeps two lanes' runs apart when they run the same command, and what lets the
|
|
908
|
+
* gate read one lane's work while another is still editing.
|
|
909
|
+
*
|
|
910
|
+
* @param {object} state - ledger state.
|
|
911
|
+
* @param {{ kind: string, detail: string, tool: string, member: string, role?: string, requirementId?: string, branch?: string, files?: string[], exitCode?: number, workaround?: boolean, ref?: string, noteId?: number, now: string }} input - evidence.
|
|
792
912
|
* @returns {object} recorded evidence.
|
|
793
913
|
*/
|
|
794
914
|
export function recordEvidence(state, input) {
|
|
@@ -801,6 +921,7 @@ export function recordEvidence(state, input) {
|
|
|
801
921
|
detail: String(input.detail).slice(0, 500),
|
|
802
922
|
tool: input.tool,
|
|
803
923
|
member: input.member,
|
|
924
|
+
requirementId: input.requirementId ?? state.activeRequirementId ?? null,
|
|
804
925
|
...optionalEvidence(input),
|
|
805
926
|
at: input.now,
|
|
806
927
|
};
|
package/lib/problem-host.js
CHANGED
|
@@ -18,10 +18,96 @@ import {
|
|
|
18
18
|
artifactRefusal,
|
|
19
19
|
closureStatus,
|
|
20
20
|
isWorkaroundRun,
|
|
21
|
-
|
|
21
|
+
laneOwningIssue,
|
|
22
|
+
laneOwningMerge,
|
|
23
|
+
mergeQueueRefusal,
|
|
22
24
|
recordEvidence,
|
|
23
25
|
} from './problem-gate.js';
|
|
24
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Normalize a branch name for comparison.
|
|
29
|
+
*
|
|
30
|
+
* @param {unknown} value - branch text.
|
|
31
|
+
* @returns {string} short branch name.
|
|
32
|
+
*/
|
|
33
|
+
function shortBranch(value) {
|
|
34
|
+
return String(value ?? '').trim().replace(/^refs\/heads\//, '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The open lanes still doing work.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} state - ledger state.
|
|
41
|
+
* @returns {object[]} lanes.
|
|
42
|
+
*/
|
|
43
|
+
function runningLanes(state) {
|
|
44
|
+
return (state.requirements ?? []).filter(
|
|
45
|
+
(requirement) => requirement.status === undefined || requirement.status === 'open',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether one directory sits inside a lane's worktree.
|
|
51
|
+
*
|
|
52
|
+
* @param {string | null} cwd - session directory.
|
|
53
|
+
* @param {unknown} worktree - lane worktree.
|
|
54
|
+
* @returns {boolean} true when the directory belongs to the lane.
|
|
55
|
+
*/
|
|
56
|
+
function insideWorktree(cwd, worktree) {
|
|
57
|
+
if (typeof cwd !== 'string' || typeof worktree !== 'string' || worktree === '') return false;
|
|
58
|
+
const base = worktree.replace(/[\\/]+$/, '');
|
|
59
|
+
return cwd === base || cwd.startsWith(`${base}/`) || cwd.startsWith(`${base}\\`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Which lane a piece of evidence was observed in.
|
|
64
|
+
*
|
|
65
|
+
* Attribution is read from the fact the tool produced: a commit names its
|
|
66
|
+
* branch, a command names the directory it ran in, a note names the thread it
|
|
67
|
+
* landed on. Each of those is set before the model speaks, so two lanes editing
|
|
68
|
+
* at once cannot claim each other's work. When nothing matches — a commit on
|
|
69
|
+
* `master`, a command run outside every worktree — the focused lane owns it,
|
|
70
|
+
* which is what a single-lane team meant anyway.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} state - ledger state.
|
|
73
|
+
* @param {{ kind: string, branch?: string, ref?: string }} evidence - observed evidence.
|
|
74
|
+
* @param {string | null} cwd - directory the call ran in.
|
|
75
|
+
* @returns {string | null} requirement id.
|
|
76
|
+
*/
|
|
77
|
+
export function laneForEvidence(state, evidence, cwd) {
|
|
78
|
+
const match = runningLanes(state).find(matcher(evidence, cwd));
|
|
79
|
+
return match?.id ?? state.activeRequirementId ?? null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The predicate that reads one kind of evidence against a lane.
|
|
84
|
+
*
|
|
85
|
+
* @param {{ kind: string, branch?: string, ref?: string }} evidence - observed evidence.
|
|
86
|
+
* @param {string | null} cwd - directory the call ran in.
|
|
87
|
+
* @returns {(lane: object) => boolean} lane predicate.
|
|
88
|
+
*/
|
|
89
|
+
function matcher(evidence, cwd) {
|
|
90
|
+
if (evidence.kind === 'commit') {
|
|
91
|
+
const branch = shortBranch(evidence.branch);
|
|
92
|
+
return (lane) => branch !== '' && shortBranch(lane.branch) === branch;
|
|
93
|
+
}
|
|
94
|
+
if (evidence.kind === 'command') return (lane) => insideWorktree(cwd, lane.worktree);
|
|
95
|
+
const ref = String(evidence.ref ?? '').trim().toLowerCase();
|
|
96
|
+
return (lane) => threadRef(lane) === ref;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The ticket reference of one lane, lowercased.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} lane - requirement record.
|
|
103
|
+
* @returns {string} `project#iid`, or an empty string.
|
|
104
|
+
*/
|
|
105
|
+
function threadRef(lane) {
|
|
106
|
+
const tracking = lane.tracking;
|
|
107
|
+
if (typeof tracking?.project !== 'string' || !Number.isInteger(tracking?.issue)) return '';
|
|
108
|
+
return `${tracking.project.trim().toLowerCase()}#${tracking.issue}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
25
111
|
/**
|
|
26
112
|
* Commit evidence from a successful `infinity_git commit` result.
|
|
27
113
|
*
|
|
@@ -134,6 +220,7 @@ export function createProblemController({
|
|
|
134
220
|
loadState,
|
|
135
221
|
memberOf,
|
|
136
222
|
isTeamAgent,
|
|
223
|
+
cwdOf = () => null,
|
|
137
224
|
withTeam = (_agent, run) => run(),
|
|
138
225
|
}) {
|
|
139
226
|
/**
|
|
@@ -150,8 +237,10 @@ export function createProblemController({
|
|
|
150
237
|
await store.mutate((draft, now) => {
|
|
151
238
|
const member = memberOf(draft, execution.agent);
|
|
152
239
|
if (member === undefined) return undefined;
|
|
240
|
+
const requirementId = laneForEvidence(draft, evidence, cwdOf(execution.agent));
|
|
153
241
|
return recordEvidence(draft, {
|
|
154
242
|
...evidence,
|
|
243
|
+
...(requirementId === null ? {} : { requirementId }),
|
|
155
244
|
member: member.name,
|
|
156
245
|
role: member.role,
|
|
157
246
|
now,
|
|
@@ -186,8 +275,13 @@ export function createProblemController({
|
|
|
186
275
|
? undefined
|
|
187
276
|
: `problem-unresolved: the team ledger is unreadable, so the verification state is unknown; refusing to merge ${target.ref}`;
|
|
188
277
|
}
|
|
189
|
-
|
|
190
|
-
|
|
278
|
+
const lane =
|
|
279
|
+
target.verb === 'close' ? laneOwningIssue(state, target) : laneOwningMerge(state, target);
|
|
280
|
+
if (target.verb === 'close' && lane === undefined) return undefined;
|
|
281
|
+
const queued = mergeQueueRefusal(state, lane, target);
|
|
282
|
+
if (queued !== undefined) return queued;
|
|
283
|
+
if (target.verb === 'merge' && lane === undefined) return undefined;
|
|
284
|
+
const status = closureStatus(state, lane);
|
|
191
285
|
return status.resolved ? undefined : artifactRefusal(target, status);
|
|
192
286
|
}
|
|
193
287
|
|
package/lib/publish-action.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { TeamError, requireSeeded } from './team-core.js';
|
|
1
|
+
import { TeamError, laneFor, requireSeeded } from './team-core.js';
|
|
2
2
|
import { activeRequirement, problemStatus, recordEvidence } from './problem-gate.js';
|
|
3
3
|
import { trackedThread, workRecordBody } from './work-record-gate.js';
|
|
4
4
|
|
|
@@ -18,12 +18,14 @@ import { trackedThread, workRecordBody } from './work-record-gate.js';
|
|
|
18
18
|
* @param {(ctx: object, operation: () => Promise<unknown>, action: string) => Promise<unknown>} deps.tracked - GitFox error mapper.
|
|
19
19
|
* @param {object} deps.ctx - Host context with the DevOps service.
|
|
20
20
|
* @param {{ member: string, role: string }} deps.caller - caller.
|
|
21
|
+
* @param {string} [deps.requirementId] - lane to publish, defaulting to the focused one.
|
|
21
22
|
* @returns {Promise<object>} published record summary.
|
|
22
23
|
*/
|
|
23
|
-
export async function publishWorkRecord({ store, loadState, tracked, ctx, caller }) {
|
|
24
|
+
export async function publishWorkRecord({ store, loadState, tracked, ctx, caller, requirementId }) {
|
|
24
25
|
const state = await loadState(store);
|
|
25
26
|
requireSeeded(state);
|
|
26
|
-
const requirement =
|
|
27
|
+
const requirement =
|
|
28
|
+
requirementId === undefined ? activeRequirement(state) : laneFor(state, requirementId);
|
|
27
29
|
if (requirement === undefined) {
|
|
28
30
|
throw new TeamError(
|
|
29
31
|
'there is no active requirement, so there is no cycle whose work record could be published',
|
|
@@ -31,7 +33,7 @@ export async function publishWorkRecord({ store, loadState, tracked, ctx, caller
|
|
|
31
33
|
{ status: 409 },
|
|
32
34
|
);
|
|
33
35
|
}
|
|
34
|
-
const thread = trackedThread(state);
|
|
36
|
+
const thread = trackedThread(state, requirement);
|
|
35
37
|
if (thread === undefined) {
|
|
36
38
|
throw new TeamError(
|
|
37
39
|
'the active requirement has no tracked ticket, and a work record belongs to a thread: open tracked intake against the GitFox project, then publish',
|
|
@@ -58,11 +60,12 @@ export async function publishWorkRecord({ store, loadState, tracked, ctx, caller
|
|
|
58
60
|
member: caller.member,
|
|
59
61
|
role: caller.role,
|
|
60
62
|
ref: thread.ref,
|
|
63
|
+
requirementId: requirement.id,
|
|
61
64
|
...(Number.isInteger(noteId) ? { noteId } : {}),
|
|
62
65
|
now,
|
|
63
66
|
});
|
|
64
67
|
});
|
|
65
|
-
const status = problemStatus(state);
|
|
68
|
+
const status = problemStatus(state, requirement);
|
|
66
69
|
return {
|
|
67
70
|
action: 'publish',
|
|
68
71
|
ref: thread.ref,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The directory a session is working in right now.
|
|
3
|
+
*
|
|
4
|
+
* `session.header.cwd` keeps the directory the session was opened with, so a
|
|
5
|
+
* worker that moved into a worktree still reports the project root there. The
|
|
6
|
+
* live directory lives on the `workingDirectory` service; the header is only the
|
|
7
|
+
* fallback for a Host without that service.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} ctx - Host context.
|
|
10
|
+
* @param {object} agent - live Agent.
|
|
11
|
+
* @returns {string | null} directory, or null when it is unknown.
|
|
12
|
+
*/
|
|
13
|
+
export function currentDirectory(ctx, agent) {
|
|
14
|
+
const session = agent?.session;
|
|
15
|
+
if (session === undefined) return null;
|
|
16
|
+
try {
|
|
17
|
+
return ctx.workingDirectory?.get?.(session) ?? session.header?.cwd ?? null;
|
|
18
|
+
} catch {
|
|
19
|
+
return session.header?.cwd ?? null;
|
|
20
|
+
}
|
|
21
|
+
}
|