tickmarkr 2.5.4 → 2.5.5
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/dist/adapters/registry.js +6 -1
- package/dist/adapters/types.d.ts +3 -0
- package/dist/cli/commands/approve.d.ts +1 -0
- package/dist/cli/commands/approve.js +54 -6
- package/dist/cli/commands/doctor.js +44 -29
- package/dist/cli/commands/fleet.js +195 -33
- package/dist/cli/commands/init.js +196 -6
- package/dist/cli/commands/resume.js +4 -2
- package/dist/cli/commands/run.js +11 -2
- package/dist/cli/commands/verify.js +1 -1
- package/dist/cli/help.d.ts +2 -0
- package/dist/cli/help.js +3 -1
- package/dist/config/config.d.ts +14 -8
- package/dist/config/config.js +29 -26
- package/dist/config/fleet-overlay.d.ts +3 -9
- package/dist/config/fleet-overlay.js +68 -11
- package/dist/config/fleet-why.d.ts +7 -0
- package/dist/config/fleet-why.js +5 -0
- package/dist/drivers/index.d.ts +15 -1
- package/dist/drivers/index.js +38 -10
- package/dist/drivers/orca.d.ts +99 -10
- package/dist/drivers/orca.js +586 -97
- package/dist/gates/baseline.d.ts +3 -0
- package/dist/gates/baseline.js +2 -1
- package/dist/gates/cache.d.ts +100 -0
- package/dist/gates/cache.js +389 -0
- package/dist/gates/run-gates.d.ts +3 -0
- package/dist/gates/run-gates.js +117 -10
- package/dist/gates/test-manifest.d.ts +99 -0
- package/dist/gates/test-manifest.js +389 -0
- package/dist/gates/test-reporter.d.ts +4 -0
- package/dist/gates/test-reporter.js +49 -0
- package/dist/route/preference.d.ts +22 -1
- package/dist/route/preference.js +123 -25
- package/dist/route/router.js +31 -6
- package/dist/run/daemon.d.ts +13 -0
- package/dist/run/daemon.js +160 -70
- package/dist/run/git.d.ts +10 -1
- package/dist/run/git.js +43 -9
- package/dist/run/journal.d.ts +14 -2
- package/dist/run/journal.js +78 -10
- package/dist/run/lease.d.ts +14 -0
- package/dist/run/lease.js +87 -0
- package/dist/run/merge.d.ts +2 -0
- package/dist/run/merge.js +91 -3
- package/dist/run/operator-state.d.ts +11 -0
- package/dist/run/operator-state.js +17 -3
- package/dist/tui/cockpit/board.d.ts +96 -0
- package/dist/tui/cockpit/board.js +346 -0
- package/dist/tui/cockpit/decision-actions.js +2 -0
- package/dist/tui/cockpit/layout.d.ts +5 -1
- package/dist/tui/cockpit/layout.js +8 -3
- package/dist/tui/cockpit/live-runtime.js +71 -21
- package/dist/tui/cockpit/run-view.d.ts +7 -5
- package/dist/tui/cockpit/run-view.js +12 -11
- package/dist/tui/ink/fleet-app.d.ts +41 -27
- package/dist/tui/ink/fleet-app.js +204 -31
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +173 -113
package/dist/run/git.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { commandLeaseEnvironment, withCommandLease } from "./lease.js";
|
|
1
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
3
|
import { spawn } from "node:child_process";
|
|
3
4
|
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
@@ -18,7 +19,7 @@ export const DEFAULT_FORK_CAP = "6";
|
|
|
18
19
|
/** Worker concurrency is resolved once per run and isolated from other async runs. */
|
|
19
20
|
const forkBudget = new AsyncLocalStorage();
|
|
20
21
|
const verificationBudget = new AsyncLocalStorage();
|
|
21
|
-
/** Verification
|
|
22
|
+
/** Verification uses command leases; workers keep their concurrency budget. */
|
|
22
23
|
export const runWithVerificationBudget = (capacity, fn) => verificationBudget.run(capacity, fn);
|
|
23
24
|
/**
|
|
24
25
|
* OBS-618: how many PROCESSES one vitest fork can hold at its peak — the fork, a daemon it spawns,
|
|
@@ -128,14 +129,17 @@ export const SPAWN_RETRY_BACKOFF_MS = 50;
|
|
|
128
129
|
// RULING-P99-28: SHIP — every gate user can hit the inherited-pipe hang at this product seam.
|
|
129
130
|
/** Give a normally-exited shell's descendants this long to close their inherited pipes themselves. */
|
|
130
131
|
const SHELL_REAP_GRACE_MS = 2000;
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
/** Shared command seam, including invocation-bound manifested runners. */
|
|
133
|
+
export function shell(cmd, cwd, timeoutMs, login = false, options = {}) {
|
|
134
|
+
return withCommandLease(cmd, () => executeShell(cmd, cwd, timeoutMs, login, options));
|
|
135
|
+
}
|
|
136
|
+
function executeShell(cmd, cwd, timeoutMs, login, options) {
|
|
137
|
+
options.signal?.throwIfAborted();
|
|
134
138
|
// OBS-74: scrub tickmarkr's own routing env seams from every child — a daemon carrying
|
|
135
139
|
// TICKMARKR_QUALITY leaked it into baseline/gate/tip-verify children, turning a dogfood
|
|
136
140
|
// repo's route() tests red inside the gates. Scrub a copy at this one choke point so
|
|
137
141
|
// children are hermetic by construction; the daemon's own process.env stays unchanged.
|
|
138
|
-
const env =
|
|
142
|
+
const env = commandLeaseEnvironment(options.env ?? process.env);
|
|
139
143
|
for (const k of ROUTING_ENV_SEAMS)
|
|
140
144
|
delete env[k];
|
|
141
145
|
// A run freezes the operator override and cores at startup; admission can lower the round cap.
|
|
@@ -163,17 +167,22 @@ function shell(cmd, cwd, timeoutMs, login) {
|
|
|
163
167
|
const stderrDecoder = new StringDecoder("utf8");
|
|
164
168
|
let timedOut = false, reapedGroup = false, done = false, started = false, outputSeen = false;
|
|
165
169
|
let reapError, exitedCode;
|
|
170
|
+
let signalExit = false;
|
|
166
171
|
let reapTimer;
|
|
172
|
+
let drainTimer;
|
|
167
173
|
const finish = (code, err) => {
|
|
168
174
|
if (done)
|
|
169
175
|
return;
|
|
170
176
|
done = true;
|
|
171
177
|
clearTimeout(timer);
|
|
172
178
|
clearTimeout(reapTimer);
|
|
179
|
+
clearTimeout(drainTimer);
|
|
180
|
+
options.signal?.removeEventListener("abort", abort);
|
|
173
181
|
stdout += stdoutDecoder.end();
|
|
174
182
|
stderr += stderrDecoder.end();
|
|
175
183
|
resolve({
|
|
176
184
|
code,
|
|
185
|
+
...(signalExit ? { signalExit: true } : {}),
|
|
177
186
|
stdout,
|
|
178
187
|
stderr: err ?? stderr,
|
|
179
188
|
timedOut,
|
|
@@ -183,8 +192,18 @@ function shell(cmd, cwd, timeoutMs, login) {
|
|
|
183
192
|
...(reapError ? { reapError } : {}),
|
|
184
193
|
});
|
|
185
194
|
};
|
|
195
|
+
// Escaped descendants can retain pipes even after the shell and its group are dead.
|
|
196
|
+
const boundDrain = () => {
|
|
197
|
+
if (!done && !drainTimer)
|
|
198
|
+
drainTimer = setTimeout(() => {
|
|
199
|
+
p.stdout?.destroy();
|
|
200
|
+
p.stderr?.destroy();
|
|
201
|
+
finish(exitedCode ?? 1);
|
|
202
|
+
}, 100);
|
|
203
|
+
};
|
|
186
204
|
const timer = setTimeout(() => {
|
|
187
205
|
timedOut = true;
|
|
206
|
+
options.onTimeout?.();
|
|
188
207
|
try {
|
|
189
208
|
process.kill(-p.pid, "SIGKILL");
|
|
190
209
|
}
|
|
@@ -198,7 +217,21 @@ function shell(cmd, cwd, timeoutMs, login) {
|
|
|
198
217
|
if (code !== "ESRCH")
|
|
199
218
|
finish(exitedCode ?? 1);
|
|
200
219
|
}
|
|
220
|
+
boundDrain();
|
|
201
221
|
}, timeoutMs);
|
|
222
|
+
const abort = () => {
|
|
223
|
+
try {
|
|
224
|
+
process.kill(-p.pid, "SIGKILL");
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
p.kill("SIGKILL");
|
|
228
|
+
}
|
|
229
|
+
boundDrain();
|
|
230
|
+
};
|
|
231
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
232
|
+
options.onSpawn?.(p.pid);
|
|
233
|
+
if (options.signal?.aborted)
|
|
234
|
+
abort();
|
|
202
235
|
p.on("spawn", () => { started = true; }); // the command exists from here on — never retryable past it
|
|
203
236
|
// OBS-716: one stateful decoder per stream carries an incomplete UTF-8 sequence into that
|
|
204
237
|
// stream's next pipe chunk; decoding each chunk through string concatenation corrupts bytes at
|
|
@@ -219,22 +252,23 @@ function shell(cmd, cwd, timeoutMs, login) {
|
|
|
219
252
|
if (!done && !started && !outputSeen && e.code === RETRYABLE_SPAWN_CODE) {
|
|
220
253
|
done = true;
|
|
221
254
|
clearTimeout(timer);
|
|
255
|
+
options.signal?.removeEventListener("abort", abort);
|
|
222
256
|
resolve({ refused: e });
|
|
223
257
|
return;
|
|
224
258
|
}
|
|
225
259
|
finish(127, String(e));
|
|
226
260
|
});
|
|
227
|
-
p.on("close", (code) => finish(code ?? 1));
|
|
261
|
+
p.on("close", (code) => { signalExit = code === null; finish(code ?? 1); });
|
|
228
262
|
// "close" waits for stdio to drain. Once bash exits normally, give descendants a bounded grace
|
|
229
263
|
// to exit with it; a survivor still in bash's detached group is then reaped so its inherited pipe
|
|
230
264
|
// cannot hold this promise until the command ceiling. A real timeout wins first and is never
|
|
231
265
|
// reclassified as a grace reap.
|
|
232
266
|
p.on("exit", (code) => {
|
|
267
|
+
signalExit = code === null;
|
|
233
268
|
exitedCode = code ?? 1;
|
|
234
|
-
|
|
235
|
-
|
|
269
|
+
// Allow a short pipe drain after killing, bounded even for an escaped descendant.
|
|
270
|
+
if (timedOut)
|
|
236
271
|
return;
|
|
237
|
-
}
|
|
238
272
|
reapTimer = setTimeout(() => {
|
|
239
273
|
if (done)
|
|
240
274
|
return;
|
package/dist/run/journal.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { type Assignment } from "../adapters/types.js";
|
|
3
3
|
import type { TickmarkrConfig } from "../config/config.js";
|
|
4
|
-
import { type GateName, type Task, type TaskStatus } from "../graph/schema.js";
|
|
4
|
+
import { type GateName, type RunGraph, type Task, type TaskStatus } from "../graph/schema.js";
|
|
5
5
|
import { type ProfileDiscount, type RoutingProfile } from "../route/profile.js";
|
|
6
6
|
import { type DecisionEventWrite, type TrackedJournalRow } from "./protocol.js";
|
|
7
7
|
export interface JournalEvent {
|
|
@@ -192,7 +192,7 @@ export declare function pendingRepairFindings(events: JournalEvent[], taskId: st
|
|
|
192
192
|
* a channel it is no longer using, and a later unrelated failure is not parked under a stale reason.
|
|
193
193
|
*/
|
|
194
194
|
export declare function activeRetryBan(events: JournalEvent[], taskId: string, channel: string): string | undefined;
|
|
195
|
-
export declare const PARK_KINDS: readonly ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota", "reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved", "infra", "dispatch", "authoring"];
|
|
195
|
+
export declare const PARK_KINDS: readonly ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota", "reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved", "infra", "dispatch", "authoring", "scope-request"];
|
|
196
196
|
export type ParkKind = (typeof PARK_KINDS)[number];
|
|
197
197
|
export declare const RETRY_MODES: readonly ["resume", "fresh", "repair"];
|
|
198
198
|
export type RetryMode = (typeof RETRY_MODES)[number];
|
|
@@ -243,6 +243,7 @@ export declare const TelemetryRowSchema: z.ZodObject<{
|
|
|
243
243
|
"merge-conflict": "merge-conflict";
|
|
244
244
|
"tip-moved": "tip-moved";
|
|
245
245
|
authoring: "authoring";
|
|
246
|
+
"scope-request": "scope-request";
|
|
246
247
|
}>>;
|
|
247
248
|
tokens: z.ZodCatch<z.ZodOptional<z.ZodObject<{
|
|
248
249
|
input: z.ZodNumber;
|
|
@@ -297,6 +298,17 @@ export declare function gateResultJournalData(gate: string, pass: boolean, detai
|
|
|
297
298
|
signalQuality: number;
|
|
298
299
|
} & Record<string, unknown>;
|
|
299
300
|
export declare function recordedGraphDefinitionHash(events: JournalEvent[]): string | undefined;
|
|
301
|
+
/** An approval is the durable authority; graph.json is only its materialized projection. */
|
|
302
|
+
export declare const ScopeAmendmentSchema: z.ZodObject<{
|
|
303
|
+
from: z.ZodString;
|
|
304
|
+
to: z.ZodString;
|
|
305
|
+
beforeFiles: z.ZodArray<z.ZodString>;
|
|
306
|
+
files: z.ZodArray<z.ZodString>;
|
|
307
|
+
parkLine: z.ZodNumber;
|
|
308
|
+
}, z.core.$strip>;
|
|
309
|
+
export declare function replayScopeAmendments(graph: RunGraph, events: JournalEvent[]): RunGraph;
|
|
310
|
+
/** Publish/recover the audit before dispatch, even after a crash between approval and rehash. */
|
|
311
|
+
export declare function applyScopeAmendments(graph: RunGraph, journal: Journal, auditReplay?: boolean): RunGraph;
|
|
300
312
|
export type EngagementCompare = {
|
|
301
313
|
comparable: true;
|
|
302
314
|
recorded: string;
|
package/dist/run/journal.js
CHANGED
|
@@ -3,7 +3,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { channelKey, shq, TokenUsageSchema } from "../adapters/types.js";
|
|
6
|
-
import { stateDirName, taskContentDigest, tickmarkrDir } from "../graph/graph.js";
|
|
6
|
+
import { graphDefinitionHash, stateDirName, taskContentDigest, tickmarkrDir } from "../graph/graph.js";
|
|
7
7
|
import { GATE_NAMES, TIERS } from "../graph/schema.js";
|
|
8
8
|
import { channelRouteIdentity } from "../route/preference.js";
|
|
9
9
|
import { buildProfile, classify } from "../route/profile.js";
|
|
@@ -773,7 +773,7 @@ const DispatchAssignmentSchema = z.object({
|
|
|
773
773
|
// routing profile's QUALITY_FAIL_PARKS (route/profile.ts): the channel did nothing wrong.
|
|
774
774
|
export const PARK_KINDS = ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota",
|
|
775
775
|
"reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved", "infra", "dispatch",
|
|
776
|
-
"authoring"];
|
|
776
|
+
"authoring", "scope-request"];
|
|
777
777
|
// v1.85 T3: "repair" is a third dispatch mode beside the v1.29 session pair — a fix-only attempt that
|
|
778
778
|
// carries the failing findings and the diff CONTENT of the work already landed, instead of re-buying
|
|
779
779
|
// ~20m of onboarding to rediscover them (62 of 68 measured re-dispatches were fresh).
|
|
@@ -971,6 +971,63 @@ export function recordedGraphDefinitionHash(events) {
|
|
|
971
971
|
}
|
|
972
972
|
return recorded ?? undefined;
|
|
973
973
|
}
|
|
974
|
+
/** An approval is the durable authority; graph.json is only its materialized projection. */
|
|
975
|
+
export const ScopeAmendmentSchema = z.object({
|
|
976
|
+
from: z.string(), to: z.string(), beforeFiles: z.array(z.string()), files: z.array(z.string()),
|
|
977
|
+
parkLine: z.number().int().positive(),
|
|
978
|
+
});
|
|
979
|
+
export function replayScopeAmendments(graph, events) {
|
|
980
|
+
const amendments = events.filter((e) => e.event === "task-approved" && e.data.release === "scope-request")
|
|
981
|
+
.map((event) => ({ event, amendment: ScopeAmendmentSchema.parse(event.data.amendment) }));
|
|
982
|
+
if (!amendments.length)
|
|
983
|
+
return graph;
|
|
984
|
+
let result = graph;
|
|
985
|
+
// Accept any materialized revision, including a fresh compilation of the original definition.
|
|
986
|
+
// Undo only recognized files[] values. Everything else must still hash to the approved definition.
|
|
987
|
+
const taskIds = new Set(amendments.map(({ event }) => event.taskId));
|
|
988
|
+
for (const id of taskIds) {
|
|
989
|
+
const history = amendments.filter(({ event }) => event.taskId === id);
|
|
990
|
+
const task = result.tasks.find((t) => t.id === id);
|
|
991
|
+
const original = history[0].amendment.beforeFiles;
|
|
992
|
+
if (!task || ![original, ...history.map(({ amendment }) => amendment.files)]
|
|
993
|
+
.some((files) => JSON.stringify(files) === JSON.stringify(task.files))) {
|
|
994
|
+
throw new Error(`refusing scope amendment replay: ${id} files[] changed beyond the approved amendment`);
|
|
995
|
+
}
|
|
996
|
+
result = { ...result, tasks: result.tasks.map((t) => t.id === id ? { ...t, files: original } : t) };
|
|
997
|
+
}
|
|
998
|
+
for (const { event, amendment } of amendments) {
|
|
999
|
+
if (graphDefinitionHash(result) !== amendment.from) {
|
|
1000
|
+
throw new Error(`refusing scope amendment replay: graph task definition changed beyond approval for ${event.taskId}`);
|
|
1001
|
+
}
|
|
1002
|
+
result = { ...result, tasks: result.tasks.map((t) => t.id === event.taskId ? { ...t, files: amendment.files } : t) };
|
|
1003
|
+
if (graphDefinitionHash(result) !== amendment.to)
|
|
1004
|
+
throw new Error("refusing invalid scope amendment hash");
|
|
1005
|
+
}
|
|
1006
|
+
return result;
|
|
1007
|
+
}
|
|
1008
|
+
/** Publish/recover the audit before dispatch, even after a crash between approval and rehash. */
|
|
1009
|
+
export function applyScopeAmendments(graph, journal, auditReplay = false) {
|
|
1010
|
+
const events = journal.read();
|
|
1011
|
+
const result = replayScopeAmendments(graph, events); // validate all before moving any identity
|
|
1012
|
+
const approvals = events.filter((e) => e.event === "task-approved" && e.data.release === "scope-request");
|
|
1013
|
+
for (const approval of approvals) {
|
|
1014
|
+
const amendment = ScopeAmendmentSchema.parse(approval.data.amendment);
|
|
1015
|
+
if (!events.some((e) => e.event === "graph-rehash" && e.data.approval === approval.ts && e.taskId === approval.taskId
|
|
1016
|
+
&& e.data.from === amendment.from && e.data.to === amendment.to)) {
|
|
1017
|
+
journal.append("graph-rehash", approval.taskId, {
|
|
1018
|
+
from: amendment.from, to: amendment.to, approval: approval.ts, by: approval.data.by, source: "approval",
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
if (auditReplay && graphDefinitionHash(graph) !== graphDefinitionHash(result) && approvals.length) {
|
|
1023
|
+
const recorded = recordedGraphDefinitionHash(journal.read());
|
|
1024
|
+
journal.append("graph-rehash", approvals.at(-1).taskId, {
|
|
1025
|
+
from: recorded, to: graphDefinitionHash(result), approval: approvals.at(-1).ts, source: "approval", replay: true,
|
|
1026
|
+
loaded: graphDefinitionHash(graph),
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
return result;
|
|
1030
|
+
}
|
|
974
1031
|
// THE shared comparator (criterion: status and resume decide through one comparator). status reads
|
|
975
1032
|
// .comparable; resume reads .comparable plus .reason/.recorded for its refusal message and the release.
|
|
976
1033
|
export function engagementComparable(events, loadedHash) {
|
|
@@ -1243,13 +1300,24 @@ export class Journal {
|
|
|
1243
1300
|
: { ts: new Date().toISOString(), event, ...(taskId ? { taskId } : {}), data: persistedData };
|
|
1244
1301
|
// T3 secret redaction: only the persisted bytes are masked — the caller's data stays untouched in
|
|
1245
1302
|
// memory. The narrator receives the persisted (masked) row so a pane sink never shows a credential.
|
|
1246
|
-
const
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1303
|
+
const rows = [row];
|
|
1304
|
+
if (event === "task-approved" && row.data.release === "scope-request") {
|
|
1305
|
+
const amendment = ScopeAmendmentSchema.parse(row.data.amendment);
|
|
1306
|
+
// Publish permission and its graph audit in one append. Otherwise a live sweep can read
|
|
1307
|
+
// permission between the two writes, race the CLI's rehash, and audit the revision twice.
|
|
1308
|
+
rows.push({ ts: row.ts, event: "graph-rehash", taskId: row.taskId, data: {
|
|
1309
|
+
from: amendment.from, to: amendment.to, approval: row.ts, by: row.data.by, source: "approval",
|
|
1310
|
+
} });
|
|
1250
1311
|
}
|
|
1251
|
-
|
|
1252
|
-
|
|
1312
|
+
const lines = rows.map((entry) => redactSecrets(JSON.stringify(entry)));
|
|
1313
|
+
appendFileSync(this.journalPath, lines.join("\n") + "\n");
|
|
1314
|
+
for (const line of lines) {
|
|
1315
|
+
try {
|
|
1316
|
+
this.narrate?.(JSON.parse(line));
|
|
1317
|
+
}
|
|
1318
|
+
catch {
|
|
1319
|
+
// narration is observational; a broken sink must not affect the journal or run
|
|
1320
|
+
}
|
|
1253
1321
|
}
|
|
1254
1322
|
if (evidence && !evidence.written && (event === "judge-retry" || event === "gate-result")) {
|
|
1255
1323
|
evidence.written = true;
|
|
@@ -1337,7 +1405,7 @@ export class Journal {
|
|
|
1337
1405
|
outstanding = parsed.success ? { assignment: parsed.data } : {}; // malformed: closable, unattributable
|
|
1338
1406
|
classified = null;
|
|
1339
1407
|
}
|
|
1340
|
-
else if (e.event === "scope-authoring" && outstanding) {
|
|
1408
|
+
else if ((e.event === "scope-authoring" || e.event === "scope-request") && outstanding) {
|
|
1341
1409
|
classified = outstanding; // a duplicate classification closes nothing more (same rule as the replay)
|
|
1342
1410
|
outstanding = null;
|
|
1343
1411
|
}
|
|
@@ -1391,7 +1459,7 @@ export class Journal {
|
|
|
1391
1459
|
st.lastAssignment = undefined;
|
|
1392
1460
|
}
|
|
1393
1461
|
}
|
|
1394
|
-
else if (e.event === "scope-authoring") {
|
|
1462
|
+
else if (e.event === "scope-authoring" || e.event === "scope-request") {
|
|
1395
1463
|
// OBS-547: the dispatch this event closes was an authoring defect — the spec is missing a
|
|
1396
1464
|
// files[] line and the worker was never at fault. Rewind its accounting so a resume neither
|
|
1397
1465
|
// charges the attempt nor treats its channel as burned.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function isRunnerCommand(command: string): boolean;
|
|
2
|
+
export declare const COMMAND_LEASE_TOKEN_ENV = "TICKMARKR_LEASE_TOKEN";
|
|
3
|
+
export type CommandLease = <T>(command: string, run: (token?: string) => Promise<T>) => Promise<T>;
|
|
4
|
+
/** Async descendants share the reservation; a shell's descendants inherit the same token. */
|
|
5
|
+
export declare function currentCommandLeaseToken(): string | undefined;
|
|
6
|
+
/** Never export a command's token through process.env: concurrent sibling commands must queue. */
|
|
7
|
+
export declare function commandLeaseEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
8
|
+
export declare const runWithCommandLease: <T>(lease: CommandLease, run: () => Promise<T>) => Promise<T>;
|
|
9
|
+
export declare const withCommandLease: <T>(command: string, run: () => Promise<T>) => Promise<T>;
|
|
10
|
+
/** Each reservation releases only itself, including an aborted waiter. */
|
|
11
|
+
export declare class CommandLeases {
|
|
12
|
+
private readonly owners;
|
|
13
|
+
run<T>(run: () => Promise<T>, waiting: (count: number) => void, pollMs: number, signal?: AbortSignal): Promise<T>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
// The process census and command admission deliberately use the same command-head classifier.
|
|
4
|
+
export function isRunnerCommand(command) {
|
|
5
|
+
// Inspect executable positions only: inference prompts and shell script bodies may name runners.
|
|
6
|
+
let head = command.trim().replace(/['"]/g, "");
|
|
7
|
+
for (;;) {
|
|
8
|
+
const unwrapped = head
|
|
9
|
+
.replace(/^(?:\S*\/)?(?:ba|z)?sh\s+-[a-z]*c\s+/i, "")
|
|
10
|
+
.replace(/^cd\s+[^;&|]+\s*&&\s*/, "")
|
|
11
|
+
.replace(/^(?:env\s+)?(?:[A-Za-z_]\w*=\S+\s+)+/, "");
|
|
12
|
+
if (unwrapped === head)
|
|
13
|
+
break;
|
|
14
|
+
head = unwrapped;
|
|
15
|
+
}
|
|
16
|
+
const binary = "(?:\\S*/)?";
|
|
17
|
+
const flags = "(?:\\s+(?:--?[\\w-]+(?:=[^\\s]+)?|--))*";
|
|
18
|
+
const manager = `${binary}(?:npm|pnpm|yarn|bun)${flags}`;
|
|
19
|
+
const jsRunner = `${binary}(?:vitest(?:\\.mjs)?|jest|mocha)(?:\\s|$)`;
|
|
20
|
+
// Test scripts may be scoped (test:unit); runner binaries may be launched through a manager.
|
|
21
|
+
// Keep each match anchored to that executable position so prompt/argument mentions stay out.
|
|
22
|
+
return new RegExp(`^${manager}\\s+(?:run${flags}\\s+)?test[\\w:.-]*(?:\\s|$)`, "i").test(head)
|
|
23
|
+
|| new RegExp(`^${manager}\\s+(?:(?:exec|x|dlx)${flags}\\s+)?${jsRunner}`, "i").test(head)
|
|
24
|
+
|| new RegExp(`^${binary}npx${flags}\\s+${jsRunner}`, "i").test(head)
|
|
25
|
+
|| /^(?:\S*\/)?(?:go|make|cargo)\s+test(?:\s|$)/i.test(head)
|
|
26
|
+
|| /^(?:(?:\S*\/)?(?:node|npx)\s+)?(?:\S*\/)?(?:vitest(?:\.mjs)?|jest|mocha|pytest(?:\d+(?:\.\d+)*)?)(?:\s|$)/i.test(head)
|
|
27
|
+
|| /^(?:\S*\/)?python[\d.]*\s+-m\s+pytest(?:\s|$)/i.test(head);
|
|
28
|
+
}
|
|
29
|
+
export const COMMAND_LEASE_TOKEN_ENV = "TICKMARKR_LEASE_TOKEN";
|
|
30
|
+
const context = new AsyncLocalStorage();
|
|
31
|
+
const ownership = new AsyncLocalStorage();
|
|
32
|
+
/** Async descendants share the reservation; a shell's descendants inherit the same token. */
|
|
33
|
+
export function currentCommandLeaseToken() {
|
|
34
|
+
const owner = ownership.getStore();
|
|
35
|
+
return owner ? (owner.active ? owner.token : undefined) : process.env[COMMAND_LEASE_TOKEN_ENV] || undefined;
|
|
36
|
+
}
|
|
37
|
+
/** Never export a command's token through process.env: concurrent sibling commands must queue. */
|
|
38
|
+
export function commandLeaseEnvironment(env) {
|
|
39
|
+
const child = { ...env };
|
|
40
|
+
const token = currentCommandLeaseToken();
|
|
41
|
+
if (token)
|
|
42
|
+
child[COMMAND_LEASE_TOKEN_ENV] = token;
|
|
43
|
+
else
|
|
44
|
+
delete child[COMMAND_LEASE_TOKEN_ENV];
|
|
45
|
+
return child;
|
|
46
|
+
}
|
|
47
|
+
export const runWithCommandLease = (lease, run) => context.run(lease, run);
|
|
48
|
+
export const withCommandLease = (command, run) => {
|
|
49
|
+
const lease = context.getStore();
|
|
50
|
+
if (!lease || !isRunnerCommand(command) || currentCommandLeaseToken())
|
|
51
|
+
return run();
|
|
52
|
+
return lease(command, token => {
|
|
53
|
+
const owner = { token: token ?? randomUUID(), active: true };
|
|
54
|
+
return ownership.run(owner, async () => {
|
|
55
|
+
try {
|
|
56
|
+
return await run();
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
owner.active = false;
|
|
60
|
+
} // A continuation after release must acquire a new lease.
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
/** Each reservation releases only itself, including an aborted waiter. */
|
|
65
|
+
export class CommandLeases {
|
|
66
|
+
owners = new Set();
|
|
67
|
+
async run(run, waiting, pollMs, signal) {
|
|
68
|
+
const owner = Symbol("command");
|
|
69
|
+
this.owners.add(owner);
|
|
70
|
+
try {
|
|
71
|
+
let reported = false;
|
|
72
|
+
while (this.owners.values().next().value !== owner) {
|
|
73
|
+
signal?.throwIfAborted();
|
|
74
|
+
if (!reported) {
|
|
75
|
+
waiting(this.owners.size - 1);
|
|
76
|
+
reported = true;
|
|
77
|
+
}
|
|
78
|
+
await new Promise((wake) => setTimeout(wake, pollMs));
|
|
79
|
+
}
|
|
80
|
+
signal?.throwIfAborted();
|
|
81
|
+
return await run();
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
this.owners.delete(owner);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
package/dist/run/merge.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface TipVerifyResult {
|
|
|
8
8
|
fingerprints: string[];
|
|
9
9
|
details: string;
|
|
10
10
|
artifact?: string;
|
|
11
|
+
reportPath?: string;
|
|
12
|
+
spawnedCommand?: string;
|
|
11
13
|
/** Q121s: nonzero exit whose failures are ALL baseline-recorded — forgiven exactly as the battery forgives. */
|
|
12
14
|
forgiven?: boolean;
|
|
13
15
|
/**
|
package/dist/run/merge.js
CHANGED
|
@@ -2,8 +2,10 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { shq } from "../adapters/types.js";
|
|
4
4
|
import { ceilingKillResult, classifyFreshRunnerOutput, classifyRunnerOutput, fileCountDeficit, waitForCalmWindow, effectiveCeilingMs, fingerprint, freshFailures, } from "../gates/baseline.js";
|
|
5
|
+
import { evaluateManifestedTest, isVitestTestCommand } from "../gates/test-manifest.js";
|
|
6
|
+
import { computeVerificationIdentity, formatReusedDetails, getVerdictStore, getWorktreeTree, resolveStateDir, isInfraResult, } from "../gates/cache.js";
|
|
5
7
|
import { tickmarkrDir } from "../graph/graph.js";
|
|
6
|
-
import { describeCapacity, gitHead, linkNodeModules, resolveIntegrationBranch, sameCapacity, sh, shGit, shGitOk, WORKTREES_DIR } from "./git.js";
|
|
8
|
+
import { describeCapacity, gitHead, linkNodeModules, resolveIntegrationBranch, resolvedCapacity, sameCapacity, sh, shGit, shGitOk, WORKTREES_DIR } from "./git.js";
|
|
7
9
|
export function integrationBranch(cfg, runId) {
|
|
8
10
|
return `${cfg.integrationBranchPrefix}${runId}`;
|
|
9
11
|
}
|
|
@@ -120,6 +122,11 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
|
|
|
120
122
|
}
|
|
121
123
|
return results;
|
|
122
124
|
}
|
|
125
|
+
const stateDir = resolveStateDir(intWt, runDir);
|
|
126
|
+
const store = getVerdictStore(stateDir);
|
|
127
|
+
// Publish only after the entire verification completes. The daemon kills a cancelled
|
|
128
|
+
// verifier process, so completed early gates must remain in memory until then.
|
|
129
|
+
const pending = new Map();
|
|
123
130
|
for (const [gate, cmd] of gatesToRun) {
|
|
124
131
|
if (runStartCommands !== undefined) {
|
|
125
132
|
const expectedCmd = gate === "test"
|
|
@@ -150,6 +157,73 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
|
|
|
150
157
|
const entry = gate === "test" && (hasTipCommand || (!identity.test && identity.tipTest))
|
|
151
158
|
? baseline?.commands.tipTest
|
|
152
159
|
: baseline?.commands[gate];
|
|
160
|
+
// A distinct tipTest selects a different forgiveness entry even when the command
|
|
161
|
+
// converges with the task command. Include that effective entry in the identity.
|
|
162
|
+
const verificationBaseline = baseline ? { commands: { ...baseline.commands } } : undefined;
|
|
163
|
+
if (verificationBaseline) {
|
|
164
|
+
if (entry)
|
|
165
|
+
verificationBaseline.commands[gate] = entry;
|
|
166
|
+
else
|
|
167
|
+
delete verificationBaseline.commands[gate];
|
|
168
|
+
}
|
|
169
|
+
// A tip verifier may reuse only a completed green from its own scope.
|
|
170
|
+
const id = await computeVerificationIdentity({
|
|
171
|
+
worktree: intWt,
|
|
172
|
+
gate,
|
|
173
|
+
scope: "tip",
|
|
174
|
+
command: cmd,
|
|
175
|
+
baseline: verificationBaseline,
|
|
176
|
+
selectedSet: undefined,
|
|
177
|
+
capacity: resolvedCapacity(),
|
|
178
|
+
});
|
|
179
|
+
// Persistent reuse requires the run's durable journal. Without it there is no
|
|
180
|
+
// recorded cycle provenance to reconcile with the caller's capacity checks.
|
|
181
|
+
const hit = journalFound ? store.get(id) : undefined;
|
|
182
|
+
if (hit && id && hit.pass && !isInfraResult(hit)) {
|
|
183
|
+
results.push({
|
|
184
|
+
gate,
|
|
185
|
+
cmd,
|
|
186
|
+
pass: hit.pass,
|
|
187
|
+
exitCode: hit.exitCode ?? 0,
|
|
188
|
+
fingerprints: hit.meta?.fingerprints ?? [],
|
|
189
|
+
...(hit.meta?.forgiven ? { forgiven: true } : {}),
|
|
190
|
+
details: formatReusedDetails(hit.details, id),
|
|
191
|
+
...(hit.meta?.reportPath ? { reportPath: hit.meta.reportPath } : {}),
|
|
192
|
+
...(hit.meta?.spawnedCommand ? { spawnedCommand: hit.meta.spawnedCommand } : {}),
|
|
193
|
+
...(hit.meta?.artifact ? { artifact: hit.meta.artifact } : {}),
|
|
194
|
+
});
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (id && journalFound)
|
|
198
|
+
pending.set(gate, id);
|
|
199
|
+
// VL-1: tip verify and standalone verify read the SAME invocation-bound report the gate would —
|
|
200
|
+
// a detected vitest test command never reaches the stdout-count/file-count path below (this
|
|
201
|
+
// gate's own manifest is always the FULL suite; verify has no selected screen to hold). A
|
|
202
|
+
// scripted test command that is not the detected runner falls through unchanged.
|
|
203
|
+
if (gate === "test" && isVitestTestCommand(cmd, intWt)) {
|
|
204
|
+
const outcome = await evaluateManifestedTest(cmd, intWt, {
|
|
205
|
+
baselineDurations: entry?.fileDurations,
|
|
206
|
+
longestFile: entry?.longestFile,
|
|
207
|
+
overallCeilingMs: effectiveCeilingMs(entry),
|
|
208
|
+
artifactDir: runDir,
|
|
209
|
+
});
|
|
210
|
+
const artifact = join(runDir, `tip-verify-${gate}.log`);
|
|
211
|
+
if (!outcome.pass)
|
|
212
|
+
writeFileSync(artifact, outcome.details);
|
|
213
|
+
results.push({
|
|
214
|
+
gate,
|
|
215
|
+
cmd,
|
|
216
|
+
pass: outcome.pass,
|
|
217
|
+
exitCode: outcome.exitCode,
|
|
218
|
+
reportPath: outcome.reportPath,
|
|
219
|
+
spawnedCommand: outcome.meta.spawnedCommand,
|
|
220
|
+
fingerprints: outcome.meta.failingTests ?? [],
|
|
221
|
+
details: outcome.details,
|
|
222
|
+
...(outcome.classification ? { cause: outcome.classification } : {}),
|
|
223
|
+
...(outcome.pass ? {} : { artifact }),
|
|
224
|
+
});
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
153
227
|
// OBS-534: the ceiling is the BATTERY's, derived by effectiveCeilingMs from the same baseline entry
|
|
154
228
|
// this loop already reads for forgiveness two lines down — never the flat DEFAULT_SHELL_TIMEOUT_MS
|
|
155
229
|
// `sh` defaults to. A suite whose capture measured 600007ms carries a recorded 1800021ms ceiling;
|
|
@@ -216,7 +290,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
|
|
|
216
290
|
const pass = !deficit && (r.code === 0 || greenTeardown || forgiven);
|
|
217
291
|
if (!pass)
|
|
218
292
|
writeFileSync(artifact, raw);
|
|
219
|
-
|
|
293
|
+
const tipResult = {
|
|
220
294
|
gate,
|
|
221
295
|
cmd,
|
|
222
296
|
pass,
|
|
@@ -233,7 +307,21 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
|
|
|
233
307
|
...(forgiven && !deficit ? { forgiven: true } : {}),
|
|
234
308
|
...(cause ? { cause } : {}),
|
|
235
309
|
...(pass ? {} : { artifact }),
|
|
236
|
-
}
|
|
310
|
+
};
|
|
311
|
+
results.push(tipResult);
|
|
312
|
+
}
|
|
313
|
+
// Recheck through the Git runner before publishing: cancellation disables further
|
|
314
|
+
// spawns in the daemon child, and a command that changed the tree invalidates reuse.
|
|
315
|
+
const completedTree = pending.size ? await getWorktreeTree(intWt) : undefined;
|
|
316
|
+
for (const result of results) {
|
|
317
|
+
const id = pending.get(result.gate);
|
|
318
|
+
if (id && id.tree === completedTree && !isInfraResult(result)) {
|
|
319
|
+
store.set(id, { ...result, meta: {
|
|
320
|
+
fingerprints: result.fingerprints, forgiven: result.forgiven,
|
|
321
|
+
reportPath: result.reportPath, spawnedCommand: result.spawnedCommand,
|
|
322
|
+
artifact: result.artifact,
|
|
323
|
+
} });
|
|
324
|
+
}
|
|
237
325
|
}
|
|
238
326
|
return results;
|
|
239
327
|
}
|
|
@@ -30,11 +30,19 @@ export interface OperatorTask {
|
|
|
30
30
|
evidence?: EvidenceIdentity;
|
|
31
31
|
mergeEvidence?: EvidenceIdentity;
|
|
32
32
|
gates: Record<string, OperatorGate>;
|
|
33
|
+
/** BD-1: the board's per-task counters — latest dispatch channel, review verdicts drawn, human parks. */
|
|
34
|
+
channel?: string;
|
|
35
|
+
reviewRounds: number;
|
|
36
|
+
parks: number;
|
|
33
37
|
}
|
|
34
38
|
export interface OperatorSnapshot {
|
|
35
39
|
sequence: number;
|
|
36
40
|
observedAt: number;
|
|
37
41
|
lastEventAt?: string;
|
|
42
|
+
firstEventAt?: string;
|
|
43
|
+
/** BD-1 header counts: run-resume and escalation rows seen. */
|
|
44
|
+
resumes: number;
|
|
45
|
+
escalations: number;
|
|
38
46
|
lifecycle: "PENDING" | "RUNNING" | "PARTIAL" | "APPROVED" | "COMPLETE" | "UNKNOWN";
|
|
39
47
|
label: string;
|
|
40
48
|
green: boolean;
|
|
@@ -63,6 +71,9 @@ export declare class OperatorStateFold {
|
|
|
63
71
|
private approved;
|
|
64
72
|
private tipFailed;
|
|
65
73
|
private lastEventAt?;
|
|
74
|
+
private firstEventAt?;
|
|
75
|
+
private resumes;
|
|
76
|
+
private escalations;
|
|
66
77
|
private passed;
|
|
67
78
|
private total;
|
|
68
79
|
apply(record: OperatorRecord): void;
|
|
@@ -16,11 +16,19 @@ export class OperatorStateFold {
|
|
|
16
16
|
approved = false;
|
|
17
17
|
tipFailed = false;
|
|
18
18
|
lastEventAt;
|
|
19
|
+
firstEventAt;
|
|
20
|
+
resumes = 0;
|
|
21
|
+
escalations = 0;
|
|
19
22
|
passed = 0;
|
|
20
23
|
total = 0;
|
|
21
24
|
apply(record) {
|
|
22
25
|
const { event: e } = record;
|
|
23
26
|
this.lastEventAt = e.ts;
|
|
27
|
+
this.firstEventAt ??= e.ts;
|
|
28
|
+
if (e.event === "run-resume")
|
|
29
|
+
this.resumes++;
|
|
30
|
+
if (e.event === "escalation")
|
|
31
|
+
this.escalations++;
|
|
24
32
|
const ref = identity(record);
|
|
25
33
|
if (e.event === "run-start" || e.event === "run-resume") {
|
|
26
34
|
if (e.event === "run-start" && !this.start) {
|
|
@@ -72,6 +80,9 @@ export class OperatorStateFold {
|
|
|
72
80
|
t.path = typeof e.data.worktree === "string" ? e.data.worktree : typeof e.data.cwd === "string" ? e.data.cwd : undefined;
|
|
73
81
|
t.pane = typeof e.data.pane === "string" ? e.data.pane : undefined;
|
|
74
82
|
t.alarmMs = typeof e.data.alarmMs === "number" ? e.data.alarmMs : undefined;
|
|
83
|
+
const a = e.data.assignment;
|
|
84
|
+
if (a && typeof a.adapter === "string" && typeof a.model === "string")
|
|
85
|
+
t.channel = `${a.adapter}:${a.model}`;
|
|
75
86
|
}
|
|
76
87
|
if (e.event === "task-done")
|
|
77
88
|
t.state = "completed";
|
|
@@ -85,6 +96,7 @@ export class OperatorStateFold {
|
|
|
85
96
|
t.state = "failed";
|
|
86
97
|
if (e.event === "task-human") {
|
|
87
98
|
t.state = "human";
|
|
99
|
+
t.parks++;
|
|
88
100
|
t.parkKind = typeof e.data.kind === "string" ? e.data.kind : undefined;
|
|
89
101
|
}
|
|
90
102
|
if (e.event === "task-blocked")
|
|
@@ -98,6 +110,8 @@ export class OperatorStateFold {
|
|
|
98
110
|
if (typeof gate === "string" && GATE_NAMES.includes(gate)) {
|
|
99
111
|
if (e.event === "gate-start")
|
|
100
112
|
t.gates[gate] = { state: "running", evidence: ref };
|
|
113
|
+
if (e.event === "gate-result" && gate === "review")
|
|
114
|
+
t.reviewRounds++;
|
|
101
115
|
if (e.event === "gate-result")
|
|
102
116
|
t.gates[gate] = {
|
|
103
117
|
state: e.data.disabled === true ? "disabled" : e.data.skipped === true ? "not-run" : e.data.pass === true ? "passed" : e.data.pass === false ? "failed" : "unknown", evidence: ref,
|
|
@@ -107,7 +121,7 @@ export class OperatorStateFold {
|
|
|
107
121
|
task(id) {
|
|
108
122
|
let t = this.tasks.get(id);
|
|
109
123
|
if (!t) {
|
|
110
|
-
t = { id, state: "unknown", merged: false, dispatches: 0, gates: emptyGates() };
|
|
124
|
+
t = { id, state: "unknown", merged: false, dispatches: 0, reviewRounds: 0, parks: 0, gates: emptyGates() };
|
|
111
125
|
this.tasks.set(id, t);
|
|
112
126
|
}
|
|
113
127
|
return t;
|
|
@@ -117,7 +131,7 @@ export class OperatorStateFold {
|
|
|
117
131
|
const comparable = this.comparableTo(hash);
|
|
118
132
|
const ids = comparable ? [...graph.tasks.map(t => t.id), ...[...this.tasks.keys()].filter(id => !graph.tasks.some(t => t.id === id))] : [...this.tasks.keys()];
|
|
119
133
|
const tasks = ids.map(id => {
|
|
120
|
-
const t = this.tasks.get(id) ?? { id, state: "unknown", merged: false, dispatches: 0, gates: emptyGates() };
|
|
134
|
+
const t = this.tasks.get(id) ?? { id, state: "unknown", merged: false, dispatches: 0, reviewRounds: 0, parks: 0, gates: emptyGates() };
|
|
121
135
|
return { ...t, title: comparable ? graph.tasks.find(g => g.id === id)?.title : undefined, gates: Object.fromEntries(Object.entries(t.gates).map(([g, cell]) => [g, { ...cell }])) };
|
|
122
136
|
});
|
|
123
137
|
const buckets = Object.fromEntries(bucketNames.map(k => [k, this.end?.buckets[k] === undefined ? undefined : [...this.end.buckets[k]]]));
|
|
@@ -134,7 +148,7 @@ export class OperatorStateFold {
|
|
|
134
148
|
&& bucketNames.every(k => buckets[k]?.length === 0);
|
|
135
149
|
const lifecycle = !readable || !this.start ? "UNKNOWN" : this.active ? "RUNNING" : this.approved ? "APPROVED" : green ? "COMPLETE" : this.end ? "PARTIAL" : "PENDING";
|
|
136
150
|
return {
|
|
137
|
-
sequence, observedAt, lastEventAt: this.lastEventAt, lifecycle,
|
|
151
|
+
sequence, observedAt, lastEventAt: this.lastEventAt, firstEventAt: this.firstEventAt, resumes: this.resumes, escalations: this.escalations, lifecycle,
|
|
138
152
|
label: this.approved ? "approved; resume required" : lifecycle,
|
|
139
153
|
green, currentTip, comparable, comparison: comparable ? "matching graph" : "not comparable",
|
|
140
154
|
merged,
|