@indigoai-us/hq-cli 5.345.62 → 5.345.64
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/CHANGELOG.md +8 -0
- package/dist/command-catalog.generated.d.ts +736 -0
- package/dist/command-catalog.generated.js +907 -0
- package/dist/commands/lanes-link.d.ts +3 -0
- package/dist/commands/lanes-link.js +83 -0
- package/dist/commands/lanes-loop.js +5 -6
- package/dist/commands/lanes-questions.js +27 -3
- package/dist/commands/lanes.js +26 -4
- package/dist/lib/daemon/catalog.d.ts +5 -0
- package/dist/lib/daemon/catalog.js +3 -0
- package/dist/lib/daemon/run.d.ts +6 -0
- package/dist/lib/daemon/run.js +13 -1
- package/dist/lib/lanes/links.d.ts +39 -0
- package/dist/lib/lanes/links.js +450 -0
- package/dist/lib/lanes/loop.d.ts +7 -2
- package/dist/lib/lanes/loop.js +169 -57
- package/dist/lib/mesh/live/flush.d.ts +7 -1
- package/dist/lib/mesh/live/flush.js +15 -7
- package/dist/lib/mesh/live/format-spool-line.d.ts +3 -1
- package/dist/lib/mesh/live/format-spool-line.js +3 -0
- package/dist/lib/mesh/live/index.d.ts +2 -0
- package/dist/lib/mesh/live/index.js +1 -0
- package/dist/lib/mesh/live/session-event.schema.json +9 -2
- package/dist/lib/mesh/live/validate-session-event.d.ts +1 -1
- package/dist/lib/mesh/live/validate-session-event.js +1 -1
- package/dist/lib/mesh/live/work-location.d.ts +30 -0
- package/dist/lib/mesh/live/work-location.js +86 -0
- package/dist/utils/version-gate.js +5 -1
- package/package.json +1 -1
package/dist/lib/lanes/loop.js
CHANGED
|
@@ -305,6 +305,9 @@ function fileEnvelope(file) {
|
|
|
305
305
|
if (!isRecord(value) || value.schema_version !== LOOP_SCHEMA || !nonEmpty(value.envelope_id) || !nonEmpty(value.queued_at)) {
|
|
306
306
|
throw new Error(`Invalid queued loop envelope record: ${file}`);
|
|
307
307
|
}
|
|
308
|
+
if (value.stall_attempts !== undefined && (!Number.isSafeInteger(value.stall_attempts) || Number(value.stall_attempts) < 0)) {
|
|
309
|
+
throw new Error(`Invalid stall_attempts in queued loop envelope record: ${file}`);
|
|
310
|
+
}
|
|
308
311
|
return { ...value, envelope: validateLoopEnvelope(value.envelope) };
|
|
309
312
|
}
|
|
310
313
|
/** Queue depth and FIFO ordering are based on the timestamp-prefixed filename. */
|
|
@@ -329,34 +332,41 @@ export function enqueueLoopEnvelope(input) {
|
|
|
329
332
|
fs.mkdirSync(dir, { recursive: true });
|
|
330
333
|
const release = acquireEnqueueLock(input.runDir);
|
|
331
334
|
try {
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
+
const withdrawnDir = path.join(input.runDir, "loop", "withdrawn");
|
|
336
|
+
const stalledDir = path.join(input.runDir, "loop", "stalled");
|
|
337
|
+
const records = [pendingDir(input.runDir), activeDir(input.runDir), claimedDir(input.runDir), stalledDir, withdrawnDir]
|
|
338
|
+
.flatMap((dir) => envelopeFiles(dir).map((file) => ({ file, dir })))
|
|
339
|
+
.map(({ file, dir }) => {
|
|
335
340
|
try {
|
|
336
|
-
return fileEnvelope(file);
|
|
341
|
+
return { file, dir, record: fileEnvelope(file) };
|
|
337
342
|
}
|
|
338
343
|
catch (error) {
|
|
339
344
|
process.stderr.write(`hq lanes: ignored unreadable loop record ${file}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
340
345
|
return undefined;
|
|
341
346
|
}
|
|
342
347
|
})
|
|
343
|
-
.filter((
|
|
344
|
-
const
|
|
345
|
-
const stalledDir = path.join(input.runDir, "loop", "stalled");
|
|
346
|
-
const reusableRecords = records.filter((record) => record.envelope_id === envelopeId);
|
|
348
|
+
.filter((entry) => entry !== undefined);
|
|
349
|
+
const reusableRecords = records.filter(({ record }) => record.envelope_id === envelopeId);
|
|
347
350
|
const terminalResult = fs.existsSync(path.join(resultsDir(input.runDir), resultName(envelopeId)))
|
|
348
351
|
? readJson(path.join(resultsDir(input.runDir), resultName(envelopeId))) : undefined;
|
|
349
|
-
const reusable = reusableRecords.length > 0 && reusableRecords.every((
|
|
352
|
+
const reusable = reusableRecords.length > 0 && reusableRecords.every(({ dir }) => dir === stalledDir || dir === withdrawnDir) &&
|
|
353
|
+
!reusableRecords.some(({ dir }) => dir === pendingDir(input.runDir) || dir === activeDir(input.runDir) || dir === claimedDir(input.runDir)) &&
|
|
354
|
+
(!isRecord(terminalResult) || terminalResult.status === "stalled");
|
|
355
|
+
if ((reusableRecords.length > 0 && !reusable) ||
|
|
356
|
+
(terminalResult !== undefined && (!reusable || !isRecord(terminalResult) || terminalResult.status !== "stalled"))) {
|
|
357
|
+
throw new Error(`envelope id ${envelopeId} was already used in this loop lane`);
|
|
358
|
+
}
|
|
359
|
+
const incomingStop = envelope.kind === "stop";
|
|
360
|
+
const blockingStop = [pendingDir(input.runDir), activeDir(input.runDir), claimedDir(input.runDir)].some((scanDir) => envelopeFiles(scanDir).some((file) => {
|
|
350
361
|
try {
|
|
351
|
-
return fileEnvelope(file).
|
|
362
|
+
return fileEnvelope(file).envelope.kind === "stop";
|
|
352
363
|
}
|
|
353
364
|
catch {
|
|
354
365
|
return false;
|
|
355
366
|
}
|
|
356
|
-
}))
|
|
357
|
-
if (
|
|
358
|
-
(
|
|
359
|
-
throw new Error(`envelope id ${envelopeId} was already used in this loop lane`);
|
|
367
|
+
}));
|
|
368
|
+
if (blockingStop) {
|
|
369
|
+
throw new Error(incomingStop ? "a stop envelope is already queued or active" : "cannot enqueue a phase after a stop envelope");
|
|
360
370
|
}
|
|
361
371
|
if (reusable) {
|
|
362
372
|
for (const file of envelopeFiles(withdrawnDir).concat(envelopeFiles(stalledDir))) {
|
|
@@ -374,23 +384,11 @@ export function enqueueLoopEnvelope(input) {
|
|
|
374
384
|
fs.renameSync(resultFile, asidePath(resultFile, "stalled"));
|
|
375
385
|
}
|
|
376
386
|
}
|
|
377
|
-
const incomingStop = envelope.kind === "stop";
|
|
378
|
-
const blockingStop = [pendingDir(input.runDir), activeDir(input.runDir)].some((scanDir) => envelopeFiles(scanDir).some((file) => {
|
|
379
|
-
try {
|
|
380
|
-
return fileEnvelope(file).envelope.kind === "stop";
|
|
381
|
-
}
|
|
382
|
-
catch {
|
|
383
|
-
return false;
|
|
384
|
-
}
|
|
385
|
-
}));
|
|
386
|
-
if (blockingStop) {
|
|
387
|
-
throw new Error(incomingStop ? "a stop envelope is already queued or active" : "cannot enqueue a phase after a stop envelope");
|
|
388
|
-
}
|
|
389
387
|
const sequence = nextQueueSequence(input.runDir);
|
|
390
388
|
const prefix = `${String(sequence).padStart(16, "0")}-${String(now.getTime()).padStart(13, "0")}-${randomUUID()}`;
|
|
391
389
|
const destination = path.join(dir, `${prefix}-${envelopeId.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
|
|
392
390
|
const temporary = `${destination}.${process.pid}.tmp`;
|
|
393
|
-
const record = { schema_version: LOOP_SCHEMA, envelope_id: envelopeId, queued_at: queuedAt, envelope };
|
|
391
|
+
const record = { schema_version: LOOP_SCHEMA, envelope_id: envelopeId, queued_at: queuedAt, stall_attempts: 0, envelope };
|
|
394
392
|
try {
|
|
395
393
|
fs.writeFileSync(temporary, `${JSON.stringify(record)}\n`, { flag: "wx", mode: 0o600 });
|
|
396
394
|
fs.renameSync(temporary, destination);
|
|
@@ -417,6 +415,12 @@ export function enqueueLoopEnvelope(input) {
|
|
|
417
415
|
export function claimNextLoopEnvelope(runDir) {
|
|
418
416
|
fs.mkdirSync(activeDir(runDir), { recursive: true });
|
|
419
417
|
fs.mkdirSync(claimedDir(runDir), { recursive: true });
|
|
418
|
+
// A previous controller may have written a stop result but failed before moving the active record to claimed.
|
|
419
|
+
for (const active of envelopeFiles(activeDir(runDir))) {
|
|
420
|
+
const record = fileEnvelope(active);
|
|
421
|
+
if (record.envelope.kind === "stop")
|
|
422
|
+
return { file: active, record };
|
|
423
|
+
}
|
|
420
424
|
for (const pending of envelopeFiles(pendingDir(runDir))) {
|
|
421
425
|
const active = path.join(activeDir(runDir), path.basename(pending));
|
|
422
426
|
try {
|
|
@@ -453,7 +457,7 @@ export function interruptLoopQueue(runDir) {
|
|
|
453
457
|
}
|
|
454
458
|
return { withdrawn, already_picked_up: alreadyPickedUp };
|
|
455
459
|
}
|
|
456
|
-
export function interruptLoopQueueForStory(runDir, storyId, phase) {
|
|
460
|
+
export function interruptLoopQueueForStory(runDir, storyId, phase, hooks = {}) {
|
|
457
461
|
const withdrawn = [];
|
|
458
462
|
const alreadyPickedUp = [];
|
|
459
463
|
const matches = (record) => {
|
|
@@ -461,28 +465,51 @@ export function interruptLoopQueueForStory(runDir, storyId, phase) {
|
|
|
461
465
|
return envelope.story_id === storyId && (phase === undefined || envelope.phase === phase);
|
|
462
466
|
};
|
|
463
467
|
for (const file of envelopeFiles(pendingDir(runDir))) {
|
|
464
|
-
|
|
468
|
+
let record;
|
|
469
|
+
hooks.beforePendingRead?.(file);
|
|
470
|
+
try {
|
|
471
|
+
record = fileEnvelope(file);
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
if (error.code === "ENOENT")
|
|
475
|
+
continue;
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
465
478
|
if (!matches(record))
|
|
466
479
|
continue;
|
|
467
480
|
const destination = path.join(runDir, "loop", "withdrawn", path.basename(file));
|
|
468
481
|
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
469
|
-
|
|
470
|
-
|
|
482
|
+
hooks.beforePendingRename?.(file);
|
|
483
|
+
try {
|
|
484
|
+
fs.renameSync(file, destination);
|
|
485
|
+
withdrawn.push(record.envelope_id);
|
|
486
|
+
}
|
|
487
|
+
catch (error) {
|
|
488
|
+
if (error.code !== "ENOENT")
|
|
489
|
+
throw error;
|
|
490
|
+
alreadyPickedUp.push(record.envelope_id);
|
|
491
|
+
}
|
|
471
492
|
}
|
|
472
493
|
for (const file of envelopeFiles(activeDir(runDir))) {
|
|
473
494
|
const record = fileEnvelope(file);
|
|
474
495
|
if (matches(record))
|
|
475
496
|
alreadyPickedUp.push(record.envelope_id);
|
|
476
497
|
}
|
|
477
|
-
return { withdrawn, already_picked_up: alreadyPickedUp };
|
|
498
|
+
return { withdrawn, already_picked_up: [...new Set(alreadyPickedUp)] };
|
|
478
499
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
? fs.readdirSync(resultsDir(
|
|
482
|
-
|
|
500
|
+
function stalledResultArchives(runDir) {
|
|
501
|
+
return fs.existsSync(resultsDir(runDir))
|
|
502
|
+
? fs.readdirSync(resultsDir(runDir)).filter((name) => /\.stalled\.\d+\.json$/.test(name))
|
|
503
|
+
: [];
|
|
504
|
+
}
|
|
505
|
+
export function findLoopStallResult(runDir, questionId) {
|
|
506
|
+
const archivedNames = new Set(stalledResultArchives(runDir));
|
|
507
|
+
const results = fs.existsSync(resultsDir(runDir))
|
|
508
|
+
? fs.readdirSync(resultsDir(runDir)).filter((name) => name.endsWith(".json") && !archivedNames.has(name)) : [];
|
|
509
|
+
const resultPath = results.map((name) => path.join(resultsDir(runDir), name)).find((file) => {
|
|
483
510
|
try {
|
|
484
511
|
const result = readJson(file);
|
|
485
|
-
return isRecord(result) && result.status === "stalled" && result.question_id ===
|
|
512
|
+
return isRecord(result) && result.status === "stalled" && result.question_id === questionId;
|
|
486
513
|
}
|
|
487
514
|
catch (error) {
|
|
488
515
|
if (error instanceof SyntaxError)
|
|
@@ -490,9 +517,35 @@ export function applyLoopStallAnswer(input) {
|
|
|
490
517
|
throw error;
|
|
491
518
|
}
|
|
492
519
|
});
|
|
493
|
-
|
|
520
|
+
return resultPath ? readJson(resultPath) : undefined;
|
|
521
|
+
}
|
|
522
|
+
export function applyLoopStallAnswer(input) {
|
|
523
|
+
const result = findLoopStallResult(input.runDir, input.questionId);
|
|
524
|
+
if (!result) {
|
|
525
|
+
for (const name of stalledResultArchives(input.runDir)) {
|
|
526
|
+
try {
|
|
527
|
+
const archived = readJson(path.join(resultsDir(input.runDir), name));
|
|
528
|
+
if (!isRecord(archived) || archived.status !== "stalled" || archived.question_id !== input.questionId)
|
|
529
|
+
continue;
|
|
530
|
+
const envelopeId = String(archived.envelope_id);
|
|
531
|
+
for (const dir of [pendingDir(input.runDir), activeDir(input.runDir), claimedDir(input.runDir)]) {
|
|
532
|
+
const match = envelopeFiles(dir).find((file) => fileEnvelope(file).envelope_id === envelopeId);
|
|
533
|
+
if (!match)
|
|
534
|
+
continue;
|
|
535
|
+
const isAnswer = path.basename(match).startsWith("0000000000000-answer-");
|
|
536
|
+
return { lane_id: path.basename(input.runDir), envelope_id: envelopeId, action: isAnswer && dir === pendingDir(input.runDir) ? "requeued" : "already_applied" };
|
|
537
|
+
}
|
|
538
|
+
return { lane_id: path.basename(input.runDir), envelope_id: envelopeId, action: "already_applied" };
|
|
539
|
+
}
|
|
540
|
+
catch (error) {
|
|
541
|
+
if (error instanceof SyntaxError || error.code === "ENOENT")
|
|
542
|
+
continue;
|
|
543
|
+
throw error;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
494
546
|
return undefined;
|
|
495
|
-
|
|
547
|
+
}
|
|
548
|
+
const resultPath = path.join(resultsDir(input.runDir), resultName(result.envelope_id));
|
|
496
549
|
const envelopeFile = envelopeFiles(path.join(input.runDir, "loop", "stalled")).find((file) => {
|
|
497
550
|
try {
|
|
498
551
|
return fileEnvelope(file).envelope_id === result.envelope_id;
|
|
@@ -506,15 +559,17 @@ export function applyLoopStallAnswer(input) {
|
|
|
506
559
|
const record = fileEnvelope(envelopeFile);
|
|
507
560
|
const action = input.choice === "Retry with a longer deadline" ? "requeued" : "parked";
|
|
508
561
|
if (action === "requeued") {
|
|
509
|
-
record.deadline_budget_seconds =
|
|
562
|
+
record.deadline_budget_seconds = (record.deadline_budget_seconds ?? phaseDeadlineBudget(record.envelope, 3600)) * 2;
|
|
563
|
+
record.stall_attempts = 0;
|
|
510
564
|
const head = path.join(pendingDir(input.runDir), `0000000000000-answer-${path.basename(envelopeFile)}`);
|
|
511
565
|
fs.mkdirSync(pendingDir(input.runDir), { recursive: true });
|
|
512
566
|
fs.renameSync(envelopeFile, head);
|
|
513
567
|
fs.renameSync(resultPath, asidePath(resultPath, "stalled"));
|
|
514
568
|
const pending = fileEnvelope(head);
|
|
515
569
|
pending.deadline_budget_seconds = record.deadline_budget_seconds;
|
|
570
|
+
pending.stall_attempts = 0;
|
|
516
571
|
atomicJson(head, pending);
|
|
517
|
-
|
|
572
|
+
persistStallCount(input.runDir, path.basename(input.runDir), record.envelope_id, 0);
|
|
518
573
|
}
|
|
519
574
|
else {
|
|
520
575
|
atomicJson(resultPath, {
|
|
@@ -613,6 +668,15 @@ function phaseDeadlineBudget(envelope, defaultSeconds) {
|
|
|
613
668
|
const configured = Number(process.env.HQ_LANE_LOOP_PHASE_DEADLINE_SECS);
|
|
614
669
|
return Number.isFinite(configured) && configured > 0 ? configured : defaultSeconds;
|
|
615
670
|
}
|
|
671
|
+
function persistStallCount(runDir, laneId, envelopeId, count) {
|
|
672
|
+
const status = loopStatus(runDir, laneId);
|
|
673
|
+
persistStatus(runDir, laneId, { stall_counts: { ...status.stall_counts, [envelopeId]: count } });
|
|
674
|
+
}
|
|
675
|
+
function clearPersistedStallCount(runDir, laneId, envelopeId) {
|
|
676
|
+
const counts = loopStatus(runDir, laneId).stall_counts;
|
|
677
|
+
delete counts[envelopeId];
|
|
678
|
+
persistStatus(runDir, laneId, { stall_counts: counts });
|
|
679
|
+
}
|
|
616
680
|
function persistStatus(runDir, laneId, patch) {
|
|
617
681
|
let current = {};
|
|
618
682
|
try {
|
|
@@ -658,7 +722,7 @@ export async function runLoopLane(options) {
|
|
|
658
722
|
const stalls = new Map();
|
|
659
723
|
let completed = 0;
|
|
660
724
|
fs.mkdirSync(options.runDir, { recursive: true });
|
|
661
|
-
|
|
725
|
+
let releaseRunner = acquireRunnerLease(options.runDir);
|
|
662
726
|
persistStatus(options.runDir, options.lane.lane_id, { pid: process.pid });
|
|
663
727
|
try {
|
|
664
728
|
const previous = readJson(loopPath(options.runDir));
|
|
@@ -673,7 +737,7 @@ export async function runLoopLane(options) {
|
|
|
673
737
|
if (error.code !== "ENOENT" && !(error instanceof SyntaxError))
|
|
674
738
|
throw error;
|
|
675
739
|
}
|
|
676
|
-
persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null
|
|
740
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
|
|
677
741
|
while (!options.signal?.aborted) {
|
|
678
742
|
const claimed = claimNextLoopEnvelope(options.runDir);
|
|
679
743
|
if (!claimed) {
|
|
@@ -685,8 +749,29 @@ export async function runLoopLane(options) {
|
|
|
685
749
|
const envelope = record.envelope;
|
|
686
750
|
if (envelope.kind === "stop") {
|
|
687
751
|
const stopped = { schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "stopped", started_at: record.queued_at, finished_at: now().toISOString() };
|
|
688
|
-
|
|
689
|
-
|
|
752
|
+
try {
|
|
753
|
+
writeResultAt(options.runDir, file, record, stopped);
|
|
754
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "stopped", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
const cleanupErrors = [];
|
|
758
|
+
try {
|
|
759
|
+
releaseRunner();
|
|
760
|
+
}
|
|
761
|
+
catch (cleanupError) {
|
|
762
|
+
cleanupErrors.push(cleanupError);
|
|
763
|
+
}
|
|
764
|
+
releaseRunner = () => { };
|
|
765
|
+
try {
|
|
766
|
+
clearLoopControllerPid(options.runDir, options.lane.lane_id, process.pid);
|
|
767
|
+
}
|
|
768
|
+
catch (cleanupError) {
|
|
769
|
+
cleanupErrors.push(cleanupError);
|
|
770
|
+
}
|
|
771
|
+
if (cleanupErrors.length)
|
|
772
|
+
throw new AggregateError([error, ...cleanupErrors], "Could not recover after committing a stop envelope");
|
|
773
|
+
throw error;
|
|
774
|
+
}
|
|
690
775
|
releaseRunner();
|
|
691
776
|
return { state: "stopped", completed };
|
|
692
777
|
}
|
|
@@ -700,12 +785,12 @@ export async function runLoopLane(options) {
|
|
|
700
785
|
const absoluteDeadline = envelope.deadline;
|
|
701
786
|
const budget = record.deadline_budget_seconds ?? firstDeadline.get(record.envelope_id) ??
|
|
702
787
|
(typeof absoluteDeadline === "string"
|
|
703
|
-
? Math.max(1, Math.round((Date.parse(absoluteDeadline) - startedAtMs) / 1000))
|
|
788
|
+
? Math.max(1, Math.round((Date.parse(absoluteDeadline) - startedAtMs) / 1000)) // An absolute deadline already in the past still gets a 1 s execution window.
|
|
704
789
|
: phaseDeadlineBudget(envelope, defaultDeadlineSeconds));
|
|
705
790
|
firstDeadline.set(record.envelope_id, budget);
|
|
706
791
|
record.deadline_budget_seconds = budget;
|
|
707
792
|
atomicJson(file, record);
|
|
708
|
-
persistStatus(options.runDir, options.lane.lane_id, { state: "running", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: record.envelope_id, active_phase: phaseName(envelope, record.envelope_id)
|
|
793
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "running", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: record.envelope_id, active_phase: phaseName(envelope, record.envelope_id) });
|
|
709
794
|
const timer = abortAfter(budget * 1000, options.signal);
|
|
710
795
|
try {
|
|
711
796
|
let result;
|
|
@@ -751,6 +836,7 @@ export async function runLoopLane(options) {
|
|
|
751
836
|
atomicJson(sessionsPath(options.runDir), Object.fromEntries(sessions));
|
|
752
837
|
}
|
|
753
838
|
stalls.delete(record.envelope_id);
|
|
839
|
+
clearPersistedStallCount(options.runDir, options.lane.lane_id, record.envelope_id);
|
|
754
840
|
completed += 1;
|
|
755
841
|
}
|
|
756
842
|
catch (error) {
|
|
@@ -759,49 +845,75 @@ export async function runLoopLane(options) {
|
|
|
759
845
|
const pending = path.join(pendingDir(options.runDir), path.basename(file));
|
|
760
846
|
fs.mkdirSync(pendingDir(options.runDir), { recursive: true });
|
|
761
847
|
fs.renameSync(file, pending);
|
|
762
|
-
persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null, stall_counts: Object.fromEntries(stalls) });
|
|
763
848
|
releaseRunner();
|
|
849
|
+
releaseRunner = () => { };
|
|
850
|
+
clearLoopControllerPid(options.runDir, options.lane.lane_id, process.pid);
|
|
851
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
|
|
764
852
|
return { state: "aborted", completed };
|
|
765
853
|
}
|
|
766
854
|
if (!timer.signal.aborted) {
|
|
767
855
|
const failure = error instanceof Error ? error.message : String(error);
|
|
768
856
|
writeResultAt(options.runDir, file, record, { schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "failed", started_at: startedAt, finished_at: now().toISOString(), error: failure });
|
|
769
857
|
completed += 1;
|
|
770
|
-
persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null
|
|
858
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
|
|
771
859
|
continue;
|
|
772
860
|
}
|
|
773
|
-
const
|
|
861
|
+
const legacyCount = stalls.get(record.envelope_id) ?? 0;
|
|
862
|
+
const attempt = (record.stall_attempts ?? legacyCount) + 1;
|
|
774
863
|
stalls.set(record.envelope_id, attempt);
|
|
775
|
-
|
|
864
|
+
persistStallCount(options.runDir, options.lane.lane_id, record.envelope_id, attempt);
|
|
865
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "running", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: record.envelope_id, active_phase: phaseName(envelope, record.envelope_id) });
|
|
776
866
|
appendJournal(options.runDir, { event: "phase_stalled", envelope_id: record.envelope_id, phase: phaseName(envelope, record.envelope_id), attempt, at: now().toISOString() });
|
|
777
867
|
if (attempt === 1) {
|
|
778
868
|
timer.clear();
|
|
779
869
|
sessions.delete(sessionKey);
|
|
780
870
|
atomicJson(sessionsPath(options.runDir), Object.fromEntries(sessions));
|
|
781
871
|
// Restore the same envelope at the FIFO head. Rename preserves a single claim.
|
|
872
|
+
record.stall_attempts = 1;
|
|
873
|
+
atomicJson(file, record);
|
|
782
874
|
const head = path.join(pendingDir(options.runDir), `0000000000000-retry-${path.basename(file)}`);
|
|
783
875
|
fs.mkdirSync(pendingDir(options.runDir), { recursive: true });
|
|
784
876
|
fs.renameSync(file, head);
|
|
785
|
-
persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null
|
|
877
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
|
|
786
878
|
continue;
|
|
787
879
|
}
|
|
788
880
|
const message = error instanceof Error ? error.message : String(error);
|
|
789
881
|
const questionId = options.raiseQuestion ? randomUUID() : undefined;
|
|
790
882
|
const result = { schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "stalled", started_at: startedAt, finished_at: now().toISOString(), error: message, ...(questionId ? { question_id: questionId } : {}) };
|
|
791
883
|
writeResultAt(options.runDir, file, record, result);
|
|
792
|
-
|
|
793
|
-
await options.raiseQuestion?.({ envelope, envelopeId: record.envelope_id, attempt, error: message, questionId });
|
|
884
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "parked", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
|
|
794
885
|
completed += 1;
|
|
795
|
-
|
|
796
|
-
|
|
886
|
+
try {
|
|
887
|
+
if (questionId)
|
|
888
|
+
await options.raiseQuestion?.({ envelope, envelopeId: record.envelope_id, attempt, error: message, questionId });
|
|
889
|
+
}
|
|
890
|
+
finally {
|
|
891
|
+
releaseRunner();
|
|
892
|
+
releaseRunner = () => { };
|
|
893
|
+
clearLoopControllerPid(options.runDir, options.lane.lane_id, process.pid);
|
|
894
|
+
}
|
|
895
|
+
const answeredRetryIsPending = envelopeFiles(pendingDir(options.runDir)).some((pendingFile) => path.basename(pendingFile).startsWith("0000000000000-answer-") && fileEnvelope(pendingFile).envelope_id === record.envelope_id);
|
|
896
|
+
if (answeredRetryIsPending) {
|
|
897
|
+
try {
|
|
898
|
+
releaseRunner = acquireRunnerLease(options.runDir);
|
|
899
|
+
persistStatus(options.runDir, options.lane.lane_id, { pid: process.pid, state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
catch (leaseError) {
|
|
903
|
+
if (!(leaseError instanceof Error) || !(leaseError.message.includes("already active") || leaseError.message.includes("claimed by another controller")))
|
|
904
|
+
throw leaseError;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
797
907
|
return { state: "parked", completed };
|
|
798
908
|
}
|
|
799
909
|
finally {
|
|
800
910
|
timer.clear();
|
|
801
911
|
}
|
|
802
|
-
persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null
|
|
912
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
|
|
803
913
|
}
|
|
804
914
|
releaseRunner();
|
|
915
|
+
clearLoopControllerPid(options.runDir, options.lane.lane_id, process.pid);
|
|
916
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
|
|
805
917
|
return { state: "aborted", completed };
|
|
806
918
|
}
|
|
807
919
|
function appendJournal(runDir, value) {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { type SessionStateFile } from "../../work-context/state.js";
|
|
12
12
|
import { type RandomFn, type SleepFn } from "./backoff.js";
|
|
13
|
+
import { type WorkLocationResolver } from "./work-location.js";
|
|
13
14
|
import { type SessionEventsPoster } from "./session-events-client.js";
|
|
14
15
|
export declare const HELD_TTL_MS: number;
|
|
15
16
|
/** Re-attempt held events at most this often (spool still flushes every cycle). */
|
|
@@ -49,6 +50,11 @@ export interface FlushDeps {
|
|
|
49
50
|
/** Passed through to bind-best (catalog cache root and HQ_WORK_MESH_CACHE). */
|
|
50
51
|
env?: NodeJS.ProcessEnv;
|
|
51
52
|
home?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Derives repo / gitBranch / folder from the local cwd before it is
|
|
55
|
+
* stripped. Defaults to a per-flush cached git resolver (tests inject).
|
|
56
|
+
*/
|
|
57
|
+
resolveWorkLocation?: WorkLocationResolver;
|
|
52
58
|
}
|
|
53
59
|
export interface FlushSummary {
|
|
54
60
|
claimed: number;
|
|
@@ -67,7 +73,7 @@ export interface FlushSummary {
|
|
|
67
73
|
* Flush spool + held. Safe to call concurrently with enqueue (claim-by-rename).
|
|
68
74
|
* Recovers orphan `*.claimed` files first; restores unprocessed lines on throw.
|
|
69
75
|
*/
|
|
70
|
-
export declare function flushSessionEvents(
|
|
76
|
+
export declare function flushSessionEvents(inputDeps: FlushDeps): Promise<FlushSummary>;
|
|
71
77
|
/**
|
|
72
78
|
* Assert flush never opens the machine-global bind cache.
|
|
73
79
|
* Used by tests; production flush has no reference to this path.
|
|
@@ -21,6 +21,7 @@ import { workContextRoot } from "../../work-context/paths.js";
|
|
|
21
21
|
import { defaultSleep, FLUSH_MAX_ATTEMPTS, fullJitterDelayMs, } from "./backoff.js";
|
|
22
22
|
import { LOCAL_ONLY_FIELDS, stripLocalOnlyFields } from "./format-spool-line.js";
|
|
23
23
|
import { enrichSessionEndEvent } from "./report-summary.js";
|
|
24
|
+
import { createWorkLocationResolver, withWorkLocation, } from "./work-location.js";
|
|
24
25
|
import { SESSION_EVENTS_BATCH_MAX, WRITABLE_CONTEXT_STATUSES, parseSessionEventsBatchAck, } from "./session-events-client.js";
|
|
25
26
|
import { appendDeadLetterLine, appendDroppedReceipt, appendHeldLinesIfAbsent, appendSpoolLine, claimHeld, claimSpool, countDistinctHeldEvents, countJsonlLines, recoverOrphanClaims, removeClaimFile, } from "./spool.js";
|
|
26
27
|
import { workMeshHeldPath, workMeshRoot } from "./paths.js";
|
|
@@ -166,7 +167,13 @@ async function postBatchWithRetries(poster, body, deps) {
|
|
|
166
167
|
* Flush spool + held. Safe to call concurrently with enqueue (claim-by-rename).
|
|
167
168
|
* Recovers orphan `*.claimed` files first; restores unprocessed lines on throw.
|
|
168
169
|
*/
|
|
169
|
-
export async function flushSessionEvents(
|
|
170
|
+
export async function flushSessionEvents(inputDeps) {
|
|
171
|
+
// One resolver per flush: git lookups are cached per cwd for this flush only.
|
|
172
|
+
const deps = {
|
|
173
|
+
...inputDeps,
|
|
174
|
+
resolveWorkLocation: inputDeps.resolveWorkLocation ?? createWorkLocationResolver(),
|
|
175
|
+
};
|
|
176
|
+
const resolveLocation = deps.resolveWorkLocation;
|
|
170
177
|
const now = deps.now ?? (() => new Date());
|
|
171
178
|
const sleep = deps.sleep ?? defaultSleep;
|
|
172
179
|
const random = deps.random ?? Math.random;
|
|
@@ -313,7 +320,7 @@ export async function flushSessionEvents(deps) {
|
|
|
313
320
|
// presence-leave). Untouched events stay in pendingRestore and are put
|
|
314
321
|
// back on the spool below.
|
|
315
322
|
const flushItems = remainingItems.slice(0, SESSION_EVENTS_MAX_PER_SESSION_PER_FLUSH);
|
|
316
|
-
const stripped = flushItems.map((item) => stripLocalOnlyFields(enrichSessionEndEvent(item.event)));
|
|
323
|
+
const stripped = flushItems.map((item) => stripLocalOnlyFields(withWorkLocation(enrichSessionEndEvent(item.event), resolveLocation)));
|
|
317
324
|
for (let i = 0; i < stripped.length; i += SESSION_EVENTS_BATCH_MAX) {
|
|
318
325
|
const chunk = stripped.slice(i, i + SESSION_EVENTS_BATCH_MAX);
|
|
319
326
|
const originalChunk = flushItems.slice(i, i + SESSION_EVENTS_BATCH_MAX);
|
|
@@ -624,12 +631,12 @@ function leaveContextStatus(leave) {
|
|
|
624
631
|
return "needs_task";
|
|
625
632
|
return "needs_project";
|
|
626
633
|
}
|
|
627
|
-
function synthesizeSessionEnd(sessionId, sample, seq, at) {
|
|
634
|
+
function synthesizeSessionEnd(sessionId, sample, seq, at, resolveLocation) {
|
|
628
635
|
const harnessRaw = eventText(sample, "harness");
|
|
629
636
|
const harness = harnessRaw && SESSION_EVENT_HARNESSES.has(harnessRaw)
|
|
630
637
|
? harnessRaw
|
|
631
638
|
: "claude-code";
|
|
632
|
-
return stripLocalOnlyFields(enrichSessionEndEvent({
|
|
639
|
+
return stripLocalOnlyFields(withWorkLocation(enrichSessionEndEvent({
|
|
633
640
|
v: 1,
|
|
634
641
|
eventId: generateUlid(),
|
|
635
642
|
kind: "session_end",
|
|
@@ -639,7 +646,7 @@ function synthesizeSessionEnd(sessionId, sample, seq, at) {
|
|
|
639
646
|
at,
|
|
640
647
|
seq,
|
|
641
648
|
cwd: eventText(sample, "cwd"),
|
|
642
|
-
}));
|
|
649
|
+
}), resolveLocation));
|
|
643
650
|
}
|
|
644
651
|
async function postPresenceLeaveIfNeeded(input) {
|
|
645
652
|
const leave = input.state?.presenceLeave;
|
|
@@ -655,13 +662,14 @@ async function postPresenceLeaveIfNeeded(input) {
|
|
|
655
662
|
: [];
|
|
656
663
|
const remaining = input.items.filter((item) => !leaveItems.includes(item));
|
|
657
664
|
const sample = leaveItems[0]?.event ?? input.items[0]?.event ?? {};
|
|
665
|
+
const resolveLocation = input.deps.resolveWorkLocation ?? createWorkLocationResolver();
|
|
658
666
|
const maxSeq = input.items.reduce((max, item) => {
|
|
659
667
|
const seq = item.event.seq;
|
|
660
668
|
return typeof seq === "number" && Number.isFinite(seq) ? Math.max(max, seq) : max;
|
|
661
669
|
}, 0);
|
|
662
|
-
const sessionEnd = synthesizeSessionEnd(input.sessionId, sample, maxSeq + 1, input.now().toISOString());
|
|
670
|
+
const sessionEnd = synthesizeSessionEnd(input.sessionId, sample, maxSeq + 1, input.now().toISOString(), resolveLocation);
|
|
663
671
|
const events = [
|
|
664
|
-
...leaveItems.map((item) => stripLocalOnlyFields(item.event)),
|
|
672
|
+
...leaveItems.map((item) => stripLocalOnlyFields(withWorkLocation(item.event, resolveLocation))),
|
|
665
673
|
sessionEnd,
|
|
666
674
|
];
|
|
667
675
|
const body = {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
export declare const LOCAL_ONLY_FIELDS: readonly ["cwd", "hqRoot", "companySlug", "project", "task", "toolWrites"];
|
|
6
6
|
export type LocalOnlyField = (typeof LOCAL_ONLY_FIELDS)[number];
|
|
7
7
|
/** Network + local key order matching work-mesh-enqueue.sh. */
|
|
8
|
-
export declare const SPOOL_KEY_ORDER: readonly ["v", "eventId", "kind", "sessionId", "harness", "adapterVersion", "runtimeVersion", "source", "at", "seq", "taskId", "status", "reason", "summary", "repo", "gitBranch", "prUrls", "cwd", "hqRoot", "companySlug", "project", "task", "toolWrites"];
|
|
8
|
+
export declare const SPOOL_KEY_ORDER: readonly ["v", "eventId", "kind", "sessionId", "harness", "adapterVersion", "runtimeVersion", "source", "at", "seq", "taskId", "status", "reason", "summary", "repo", "gitBranch", "folder", "prUrls", "cwd", "hqRoot", "companySlug", "project", "task", "toolWrites"];
|
|
9
9
|
export type SessionEventKind = "session_start" | "turn_start" | "turn_end" | "session_end" | "task_status" | "blocked" | "note";
|
|
10
10
|
export type SessionEventHarness = "claude-code" | "claude-desktop" | "codex" | "grok" | "hq-sessions" | "agent-box" | "agents-v2";
|
|
11
11
|
export type SessionEventSource = "hooks" | "transcript";
|
|
@@ -26,6 +26,8 @@ export interface SpoolEventInput {
|
|
|
26
26
|
summary?: string;
|
|
27
27
|
repo?: string;
|
|
28
28
|
gitBranch?: string;
|
|
29
|
+
/** Network-only: basename of the session cwd (never a path). */
|
|
30
|
+
folder?: string;
|
|
29
31
|
prUrls?: string[];
|
|
30
32
|
cwd?: string;
|
|
31
33
|
hqRoot?: string;
|
|
@@ -28,6 +28,7 @@ export const SPOOL_KEY_ORDER = [
|
|
|
28
28
|
"summary",
|
|
29
29
|
"repo",
|
|
30
30
|
"gitBranch",
|
|
31
|
+
"folder",
|
|
31
32
|
"prUrls",
|
|
32
33
|
"cwd",
|
|
33
34
|
"hqRoot",
|
|
@@ -70,6 +71,8 @@ export function buildOrderedSpoolObject(input) {
|
|
|
70
71
|
out.repo = input.repo;
|
|
71
72
|
if (includeString(input.gitBranch))
|
|
72
73
|
out.gitBranch = input.gitBranch;
|
|
74
|
+
if (includeString(input.folder) && !/[/\\]/.test(input.folder))
|
|
75
|
+
out.folder = input.folder;
|
|
73
76
|
if (Array.isArray(input.prUrls) && input.prUrls.length > 0) {
|
|
74
77
|
out.prUrls = input.prUrls.filter(includeString).slice(0, 5);
|
|
75
78
|
}
|
|
@@ -7,6 +7,8 @@ export type { ProhibitedContentClass, SessionEventValidationResult, } from "./va
|
|
|
7
7
|
export { InvalidSessionIdentityError, SESSION_ID_ENV_KEYS, isValidSessionId, normalizeSessionIdentity, sessionIdFromEnv, } from "./session-identity.js";
|
|
8
8
|
export type { NormalizeSessionIdentityInput, NormalizedSessionIdentity, } from "./session-identity.js";
|
|
9
9
|
export { LOCAL_ONLY_FIELDS, SPOOL_KEY_ORDER, buildOrderedSpoolObject, formatSpoolLine, stripLocalOnlyFields, } from "./format-spool-line.js";
|
|
10
|
+
export { createWorkLocationResolver, folderFromCwd, resolveWorkLocation, withWorkLocation, } from "./work-location.js";
|
|
11
|
+
export type { WorkLocation, WorkLocationResolver } from "./work-location.js";
|
|
10
12
|
export type { LocalOnlyField, SessionEventHarness, SessionEventKind, SessionEventSource, SpoolEventInput, } from "./format-spool-line.js";
|
|
11
13
|
export { encodeCrockford, generateUlid, isUlid } from "./ulid.js";
|
|
12
14
|
export { workMeshClaimPath, workMeshDeadLetterPath, workMeshDroppedReceiptPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "./paths.js";
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
export { getSessionEventValidator, PROHIBITED_CONTENT_CLASSES, PROHIBITED_FIELD_BY_CLASS, redactValidationMessage, resolveHqProSchemaPath, SESSION_EVENT_SCHEMA_PATH, SESSION_EVENT_SCHEMA_SHA256, sha256File, sha256SessionEventSchema, validateSessionEvent, } from "./validate-session-event.js";
|
|
6
6
|
export { InvalidSessionIdentityError, SESSION_ID_ENV_KEYS, isValidSessionId, normalizeSessionIdentity, sessionIdFromEnv, } from "./session-identity.js";
|
|
7
7
|
export { LOCAL_ONLY_FIELDS, SPOOL_KEY_ORDER, buildOrderedSpoolObject, formatSpoolLine, stripLocalOnlyFields, } from "./format-spool-line.js";
|
|
8
|
+
export { createWorkLocationResolver, folderFromCwd, resolveWorkLocation, withWorkLocation, } from "./work-location.js";
|
|
8
9
|
export { encodeCrockford, generateUlid, isUlid } from "./ulid.js";
|
|
9
10
|
export { workMeshClaimPath, workMeshDeadLetterPath, workMeshDroppedReceiptPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "./paths.js";
|
|
10
11
|
export { SPOOL_LINE_MAX_BYTES, SpoolWriteError, appendDeadLetterLine, appendDroppedReceipt, appendHeldLine, appendJsonlLine, appendSpoolLine, claimHeld, claimJsonlByRename, claimSpool, countJsonlLines, deadLetterNonEmpty, ensureSpoolFile, removeClaimFile, } from "./spool.js";
|
|
@@ -115,13 +115,20 @@
|
|
|
115
115
|
"type": "string",
|
|
116
116
|
"minLength": 1,
|
|
117
117
|
"maxLength": 128,
|
|
118
|
-
"description": "Optional git repository basename
|
|
118
|
+
"description": "Optional git repository basename of the session cwd. Recorded on the session's live row (latest wins)."
|
|
119
119
|
},
|
|
120
120
|
"gitBranch": {
|
|
121
121
|
"type": "string",
|
|
122
122
|
"minLength": 1,
|
|
123
123
|
"maxLength": 128,
|
|
124
|
-
"description": "Optional git branch of the session cwd on
|
|
124
|
+
"description": "Optional git branch of the session cwd. Recorded on the session's live row (latest wins)."
|
|
125
|
+
},
|
|
126
|
+
"folder": {
|
|
127
|
+
"type": "string",
|
|
128
|
+
"minLength": 1,
|
|
129
|
+
"maxLength": 128,
|
|
130
|
+
"pattern": "^[^/\\\\]+$",
|
|
131
|
+
"description": "Optional basename of the session working folder. Never a path. Recorded on the session's live row (latest wins)."
|
|
125
132
|
},
|
|
126
133
|
"prUrls": {
|
|
127
134
|
"type": "array",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { ValidateFunction } from "ajv";
|
|
9
9
|
export declare const SESSION_EVENT_SCHEMA_PATH: string;
|
|
10
10
|
/** Expected SHA-256 of the canonical schema bytes (hq-pro + hq-cli must match). */
|
|
11
|
-
export declare const SESSION_EVENT_SCHEMA_SHA256 = "
|
|
11
|
+
export declare const SESSION_EVENT_SCHEMA_SHA256 = "28cbf11ac0e4ebbdfbfd65f51c4f547a58523bd46c98429976bf8df53d404f0f";
|
|
12
12
|
export declare const PROHIBITED_CONTENT_CLASSES: readonly ["prompts", "model_output", "transcripts", "message_bodies", "tokens", "credentials"];
|
|
13
13
|
export type ProhibitedContentClass = (typeof PROHIBITED_CONTENT_CLASSES)[number];
|
|
14
14
|
/** Fixture field name → prohibited class. */
|
|
@@ -13,7 +13,7 @@ import Ajv2020 from "ajv/dist/2020.js";
|
|
|
13
13
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
export const SESSION_EVENT_SCHEMA_PATH = join(HERE, "session-event.schema.json");
|
|
15
15
|
/** Expected SHA-256 of the canonical schema bytes (hq-pro + hq-cli must match). */
|
|
16
|
-
export const SESSION_EVENT_SCHEMA_SHA256 = "
|
|
16
|
+
export const SESSION_EVENT_SCHEMA_SHA256 = "28cbf11ac0e4ebbdfbfd65f51c4f547a58523bd46c98429976bf8df53d404f0f";
|
|
17
17
|
export const PROHIBITED_CONTENT_CLASSES = [
|
|
18
18
|
"prompts",
|
|
19
19
|
"model_output",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Work Mesh Live — network-safe work location derived from the local cwd.
|
|
3
|
+
*
|
|
4
|
+
* `cwd` is a local-only spool field and never leaves the machine. Before an
|
|
5
|
+
* event is sent, the flush derives three short labels from it:
|
|
6
|
+
* - repo: basename of `git rev-parse --show-toplevel` (omitted outside git)
|
|
7
|
+
* - gitBranch: `git rev-parse --abbrev-ref HEAD` (omitted on detached HEAD)
|
|
8
|
+
* - folder: basename of the cwd, never a path (no "/" or "\")
|
|
9
|
+
* Git lookups are cached per cwd for one flush, bounded by a short timeout,
|
|
10
|
+
* and never throw; on failure the fields are omitted.
|
|
11
|
+
*/
|
|
12
|
+
export type GitRunner = (cwd: string, args: string[]) => string | undefined;
|
|
13
|
+
export interface WorkLocation {
|
|
14
|
+
repo?: string;
|
|
15
|
+
gitBranch?: string;
|
|
16
|
+
folder?: string;
|
|
17
|
+
}
|
|
18
|
+
export type WorkLocationResolver = (cwd: string) => WorkLocation;
|
|
19
|
+
/** Last path segment of `cwd` (POSIX or Windows separators), or undefined. */
|
|
20
|
+
export declare function folderFromCwd(cwd: string): string | undefined;
|
|
21
|
+
/** Resolve the work location for one cwd. Never throws. */
|
|
22
|
+
export declare function resolveWorkLocation(cwd: string, git?: GitRunner): WorkLocation;
|
|
23
|
+
/** A resolver that caches results per cwd (create one per flush). */
|
|
24
|
+
export declare function createWorkLocationResolver(git?: GitRunner): WorkLocationResolver;
|
|
25
|
+
/**
|
|
26
|
+
* Copy of `event` with repo / gitBranch / folder filled from its local cwd.
|
|
27
|
+
* Values already on the event win. Call before stripLocalOnlyFields.
|
|
28
|
+
*/
|
|
29
|
+
export declare function withWorkLocation(event: Record<string, unknown>, resolve: WorkLocationResolver): Record<string, unknown>;
|
|
30
|
+
//# sourceMappingURL=work-location.d.ts.map
|