@signalridge/pi-workflows 1.9.0 → 1.10.1
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 +10 -0
- package/README.md +33 -1
- package/package.json +1 -1
- package/skills/workflow-authoring/SKILL.md +1 -1
- package/skills/workflow-authoring/references/capabilities.md +1 -1
- package/skills/workflow-authoring/references/capability-details.md +1 -1
- package/skills/workflow-authoring/references/lifecycle.md +3 -1
- package/skills/workflow-patterns/SKILL.md +1 -1
- package/skills/workflow-review/SKILL.md +1 -1
- package/src/engine.ts +120 -53
- package/src/index.ts +117 -33
- package/src/runtime.ts +25 -16
- package/src/status-actions.ts +143 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.10.1
|
|
4
|
+
### Patch Changes
|
|
5
|
+
|
|
6
|
+
- c00f9f9: Wait for the exact terminal journal append receipt before publishing workflow completion, and recheck soft run/phase budgets after queue admission and before retry attempts.
|
|
7
|
+
|
|
8
|
+
## 1.10.0
|
|
9
|
+
### Minor Changes
|
|
10
|
+
|
|
11
|
+
- a4dc1de: Expose optional session/branch-owned status navigation to the existing workflow navigator and add compact run-authoritative footer counts without changing execution or widget behavior.
|
|
12
|
+
|
|
3
13
|
## 1.9.0
|
|
4
14
|
### Minor Changes
|
|
5
15
|
|
package/README.md
CHANGED
|
@@ -62,7 +62,13 @@ This is not a wall-clock deadline for legitimate agent awaits, and never polls a
|
|
|
62
62
|
model. Cancellation/failure closes the actual owned helper through TERM, KILL
|
|
63
63
|
escalation after 250ms, and a bounded 1.5-second close wait. Managed children must
|
|
64
64
|
still settle exact-owner cleanup before terminal publication or resumability; a
|
|
65
|
-
cleanup failure is fenced, not claimed as success.
|
|
65
|
+
cleanup failure is fenced, not claimed as success. Terminal status, final results,
|
|
66
|
+
waiters and background notifications also wait for the exact accepted terminal
|
|
67
|
+
transition's journal append to succeed (an append receipt, not an fsync guarantee).
|
|
68
|
+
A transient append rejection retains the immutable transition for bounded retries.
|
|
69
|
+
Exhaustion never reports success: a receipted persistence failure may settle the run;
|
|
70
|
+
otherwise terminal publication remains pending. Stop or branch replacement
|
|
71
|
+
invalidates a queued success. A returned value closes new
|
|
66
72
|
root-call admission but drains every accepted native operation and nested frame
|
|
67
73
|
before success; callbacks needed by those operations remain available. Infinite
|
|
68
74
|
awaits in forgotten helpers still require cancellation. Ignored native RPC promises
|
|
@@ -163,6 +169,14 @@ user's own LLM and run on the user's own machine.
|
|
|
163
169
|
- `phase(title, { budget })`, `log(message)`, `args`, `cwd`, `process.cwd()`,
|
|
164
170
|
`budget` (`{ total, spent(), remaining() }`).
|
|
165
171
|
|
|
172
|
+
Run and phase token budgets are soft pre-dispatch gates: they recheck completed
|
|
173
|
+
spend after acquiring runtime capacity and before every provider-agent attempt,
|
|
174
|
+
including empty-output retries and schema repairs. Queued calls do not retain an
|
|
175
|
+
earlier budget approval. Attempts already running can overshoot; this is not a
|
|
176
|
+
hard token or cost cap. Nested live calls share run spend, while phase allowances
|
|
177
|
+
remain frame-local and queued calls keep their captured phase allowance. Journal
|
|
178
|
+
replay neither re-executes nor charges completed calls.
|
|
179
|
+
|
|
166
180
|
### Resume
|
|
167
181
|
|
|
168
182
|
A script edit replays the longest unchanged prefix from the journal and runs
|
|
@@ -544,3 +558,21 @@ used it.
|
|
|
544
558
|
- **Schema validation is client-side.** The managed request does not assume a structured-output tool; the runtime asks for JSON, parses, validates (including `additionalProperties`), and repairs across bounded attempts.
|
|
545
559
|
|
|
546
560
|
State is persisted as `pi.appendEntry("pi-workflows:journal", ...)` custom entries (schema v4), including frozen invocation args, resolved tier identity, terminal result previews, script revisions, named nested-workflow result boundaries, and durable removals. Interrupted/provider-limited runs replay from the journal; pre-schema-v4 journals are quarantined rather than replayed. Foreground `AbortSignal`s stop owned children through explicit owner-scoped stop/quiescence, and dispose/branch replacement reject stale waiters. Navigation preflight vetoes while agents or workflows are active or cleaning up, without interrupting them. An accepted tree commit fences old callbacks in either extension load order and restores only the committed branch; preflight itself is not a commit point.
|
|
561
|
+
|
|
562
|
+
## Status navigation
|
|
563
|
+
|
|
564
|
+
With a compatible status selector, **Ctrl+Alt+S**, choose **Workflow runs**, then
|
|
565
|
+
**Enter** opens this package's existing workflow navigator. `/workflows` and its
|
|
566
|
+
watch/detail paths remain unchanged. Querying this optional TUI-only endpoint
|
|
567
|
+
never opens a screen, starts/resumes a run, or sends a model/user message. Each
|
|
568
|
+
selection belongs to one live session/branch surface; navigation and reload
|
|
569
|
+
retire it rather than redirecting an old selection to another owner. Only
|
|
570
|
+
bounded identifiers and labels cross this presentation wire, not execution
|
|
571
|
+
controls, source, logs, or callbacks.
|
|
572
|
+
|
|
573
|
+
The compact `pi-workflows` footer reports `A` active runs (pending, running,
|
|
574
|
+
pausing, stopping) and `P` paused/interrupted runs. With only retained terminal
|
|
575
|
+
runs it reports `C` completed, `F` failed, and `S` stopped counts; an empty run
|
|
576
|
+
list clears it. Counts reflect run status, never inferred child completion.
|
|
577
|
+
This uses the existing progress refresh cadence without adding another widget
|
|
578
|
+
or taking focus.
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: workflow-authoring
|
|
3
3
|
description: Design, write, review, and debug Pi JavaScript workflows that coordinate multiple subagents. Use when a request needs named dependencies, bounded fan-out, per-item pipelines, quality gates, saved or nested workflows, pause/resume, or workflow progress; do not use it merely to run an existing named workflow.
|
|
4
4
|
metadata:
|
|
5
|
-
version: "1.
|
|
5
|
+
version: "1.10.1"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Workflow authoring
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Workflow capability index
|
|
3
3
|
|
|
4
4
|
Contract format: `2.0.0`<br>
|
|
5
|
-
Contract content / skill / extension: `1.
|
|
5
|
+
Contract content / skill / extension: `1.10.1`
|
|
6
6
|
|
|
7
7
|
This compact generated index covers supported runtime globals and workflow-tool inputs. For constraints, compatibility behavior, internal boundaries, and dynamic-reference ownership, follow the [exhaustive generated facts](capability-details.md).
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Exhaustive workflow capability facts
|
|
3
3
|
|
|
4
4
|
Contract format: `2.0.0`<br>
|
|
5
|
-
Contract content / skill / extension: `1.
|
|
5
|
+
Contract content / skill / extension: `1.10.1`
|
|
6
6
|
|
|
7
7
|
Every exact fact below is projected from the installed extension's capability contract. Explanatory judgment belongs in the hand-written references next to this file.
|
|
8
8
|
|
|
@@ -8,7 +8,7 @@ Bound the work where the work is decided: graph tasks, fan-out items, loop round
|
|
|
8
8
|
|
|
9
9
|
That pool is typically small — 4 slots unless the host was configured otherwise — so assume a wide fan-out runs in waves, not all at once. This does not change how wide to make it: the agents all run either way, and the reason to split work is coverage rather than wall-clock. It does mean a script should not promise the user a fast answer because it fanned out, and should not add a checkpoint or a progress phase that only makes sense if everything starts together.
|
|
10
10
|
|
|
11
|
-
Enter a phase budget with `phase("Name", { budget: N })`; phase metadata does not carry budgets. `N` is a token allowance, not a call or round count: size it for the intended agent work instead of copying a small iteration limit. Token and phase budgets are soft pre-
|
|
11
|
+
Enter a phase budget with `phase("Name", { budget: N })`; phase metadata does not carry budgets. `N` is a token allowance, not a call or round count: size it for the intended agent work instead of copying a small iteration limit. Token and phase budgets are soft pre-dispatch gates, rechecked after acquiring runtime capacity and before every provider-agent attempt (including empty-output retries and schema repairs). Queued calls keep their captured phase allowance, not an earlier budget approval or a later phase rebase. Spend lands after agents finish, so concurrent work can overshoot; these gates are not hard token or cost caps. Phase allowances remain frame-local; a phase budget neither reserves tokens nor cancels active calls. `budget.spent()` and `budget.remaining()` include shared nested run spend.
|
|
12
12
|
|
|
13
13
|
## Checkpoints
|
|
14
14
|
|
|
@@ -30,6 +30,8 @@ Resume replays only the longest unchanged prefix of journaled calls. Once one ca
|
|
|
30
30
|
|
|
31
31
|
Only a call that finishes with a real result is journaled. A call whose every attempt was recoverable (including one that only ever produced `AGENT_EMPTY_OUTPUT`) contributes no journal entry, so resuming that run reruns exactly that call and everything lexically after it live; the earlier, already-succeeded prefix still replays from cache. Schema repair retries include the concrete validation errors in the next prompt.
|
|
32
32
|
|
|
33
|
+
Terminal status, final results, waiters and background notifications wait for the exact accepted terminal transition's journal append receipt, as well as existing owned-cleanup fences. This is an append guarantee, not an fsync guarantee. Transient append rejection retains the immutable transition for bounded retries. Exhaustion never reports success: a receipted persistence failure may settle the run; otherwise terminal publication remains pending. Stop or branch replacement invalidates a queued success.
|
|
34
|
+
|
|
33
35
|
The runtime blocks common accidental nondeterminism, but this is not a security boundary. Pass timestamps, randomness, and external decisions through `args`.
|
|
34
36
|
|
|
35
37
|
## Nesting and shared state
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: workflow-patterns
|
|
3
3
|
description: Choose and run one of Pi's built-in multi-agent workflows for research, adversarial review, code review, multi-perspective analysis, or codebase auditing. Use when the request matches a named pattern; use workflow-authoring instead for a new script or custom topology.
|
|
4
4
|
metadata:
|
|
5
|
-
version: "1.
|
|
5
|
+
version: "1.10.1"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Built-in workflow patterns
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: workflow-review
|
|
3
3
|
description: Audit an existing Pi JavaScript workflow for topology, prompt/data flow, limits, failure handling, cancellation, resume safety, and publication compatibility. Use when reviewing or debugging a workflow script or saved run; do not use to run a standard named workflow.
|
|
4
4
|
metadata:
|
|
5
|
-
version: "1.
|
|
5
|
+
version: "1.10.1"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Workflow review
|
package/src/engine.ts
CHANGED
|
@@ -245,6 +245,8 @@ export interface ScriptRunState {
|
|
|
245
245
|
>;
|
|
246
246
|
/** Journal retry bookkeeping. */
|
|
247
247
|
pendingJournal: Set<string>;
|
|
248
|
+
/** Exact terminal append still awaiting its receipt; not a published run status. */
|
|
249
|
+
pendingTerminal?: object;
|
|
248
250
|
/** Whether a lifecycle suspension is in effect (branch change). */
|
|
249
251
|
lifecycleSuspended: boolean;
|
|
250
252
|
}
|
|
@@ -490,6 +492,8 @@ export class WorkflowEngine {
|
|
|
490
492
|
const epoch = this.branchEpoch;
|
|
491
493
|
if (
|
|
492
494
|
!this.isCurrentState(state, generation, epoch) ||
|
|
495
|
+
!isTerminalWorkflow(state.run.status) ||
|
|
496
|
+
state.pendingTerminal !== undefined ||
|
|
493
497
|
state.pendingCleanup.size > 0 ||
|
|
494
498
|
this.terminalNotified.has(state)
|
|
495
499
|
)
|
|
@@ -519,6 +523,7 @@ export class WorkflowEngine {
|
|
|
519
523
|
state.pendingSpawns.size > 0 ||
|
|
520
524
|
state.agentWaiters.size > 0 ||
|
|
521
525
|
state.pendingJournal.size > 0 ||
|
|
526
|
+
state.pendingTerminal !== undefined ||
|
|
522
527
|
state.pendingCleanup.size > 0 ||
|
|
523
528
|
state.activeExecutionGenerations.size > 0 ||
|
|
524
529
|
state.stopOperation !== undefined ||
|
|
@@ -1111,8 +1116,11 @@ export class WorkflowEngine {
|
|
|
1111
1116
|
if (!(error instanceof WorkflowWaitAbortedError)) throw error;
|
|
1112
1117
|
// Foreground cancellation owns execution, not just this waiter. Do not
|
|
1113
1118
|
// advertise resumability until runtime children and checkpoint UI unwind.
|
|
1119
|
+
// A failed receipt can outlive execution; retire it before aborting too.
|
|
1120
|
+
state.pendingTerminal = undefined;
|
|
1114
1121
|
state.controller.abort();
|
|
1115
1122
|
await this.drainExecution(state, execution);
|
|
1123
|
+
this.recoverInterruptedExecution(state, generation, epoch);
|
|
1116
1124
|
const current = this.runs.get(runId)?.run ?? run;
|
|
1117
1125
|
return { runId: current.runId, status: current.status, background: false, waitAborted: true };
|
|
1118
1126
|
}
|
|
@@ -1274,8 +1282,10 @@ export class WorkflowEngine {
|
|
|
1274
1282
|
return { runId, status: settled.status, background: false };
|
|
1275
1283
|
} catch (error: unknown) {
|
|
1276
1284
|
if (!(error instanceof WorkflowWaitAbortedError)) throw error;
|
|
1285
|
+
state.pendingTerminal = undefined;
|
|
1277
1286
|
state.controller.abort();
|
|
1278
1287
|
await this.drainExecution(state, execution);
|
|
1288
|
+
this.recoverInterruptedExecution(state, generation, epoch);
|
|
1279
1289
|
return { runId, status: run.status, background: false, waitAborted: true };
|
|
1280
1290
|
}
|
|
1281
1291
|
}
|
|
@@ -1297,6 +1307,8 @@ export class WorkflowEngine {
|
|
|
1297
1307
|
if (action === "get") return { action, run: this.summary(run, true) };
|
|
1298
1308
|
if (action === "pause") {
|
|
1299
1309
|
if (run.status === "running") {
|
|
1310
|
+
const generation = state.executionGeneration;
|
|
1311
|
+
const epoch = this.branchEpoch;
|
|
1300
1312
|
this.setWorkflowStatus(run, "pausing");
|
|
1301
1313
|
this.reconcilePendingSpawns(state);
|
|
1302
1314
|
// Abort the script execution; the run settles as interrupted and can be
|
|
@@ -1304,6 +1316,7 @@ export class WorkflowEngine {
|
|
|
1304
1316
|
// the runtime's abort signal via the dispatch waiters.
|
|
1305
1317
|
state.controller.abort();
|
|
1306
1318
|
await this.drainExecution(state, state.execution);
|
|
1319
|
+
this.recoverInterruptedExecution(state, generation, epoch);
|
|
1307
1320
|
} else if (run.status !== "paused" && run.status !== "interrupted") {
|
|
1308
1321
|
throw new Error(`cannot pause workflow in state ${run.status}`);
|
|
1309
1322
|
}
|
|
@@ -1381,7 +1394,15 @@ export class WorkflowEngine {
|
|
|
1381
1394
|
}
|
|
1382
1395
|
|
|
1383
1396
|
private settleWaiters(runId: string, run: ScriptRun): void {
|
|
1384
|
-
|
|
1397
|
+
const state = this.runs.get(runId);
|
|
1398
|
+
if (
|
|
1399
|
+
state?.run !== run ||
|
|
1400
|
+
!this.isCurrentState(state, state.executionGeneration, this.branchEpoch) ||
|
|
1401
|
+
state.pendingTerminal !== undefined ||
|
|
1402
|
+
this.hasPendingCleanup(runId) ||
|
|
1403
|
+
(!isTerminalWorkflow(run.status) && run.status !== "paused" && run.status !== "interrupted")
|
|
1404
|
+
)
|
|
1405
|
+
return;
|
|
1385
1406
|
const all = this.waiters.get(runId);
|
|
1386
1407
|
if (!all) return;
|
|
1387
1408
|
for (const waiter of [...all]) {
|
|
@@ -1396,6 +1417,43 @@ export class WorkflowEngine {
|
|
|
1396
1417
|
|
|
1397
1418
|
// ── execution ──────────────────────────────────────────────────────────────
|
|
1398
1419
|
|
|
1420
|
+
private recoverInterruptedExecution(state: ScriptRunState, generation: number, epoch: number): void {
|
|
1421
|
+
const run = state.run;
|
|
1422
|
+
const current = () =>
|
|
1423
|
+
this.runs.get(run.runId) === state &&
|
|
1424
|
+
state.executionGeneration === generation &&
|
|
1425
|
+
this.branchEpoch === epoch &&
|
|
1426
|
+
!this.disposed &&
|
|
1427
|
+
!state.stopOperation &&
|
|
1428
|
+
(run.status === "running" || run.status === "pausing");
|
|
1429
|
+
if (!current()) return;
|
|
1430
|
+
// Cancellation takes ownership before interruption is visible. An abandoned
|
|
1431
|
+
// completed/failed receipt must neither publish later nor block old waiters.
|
|
1432
|
+
state.pendingTerminal = undefined;
|
|
1433
|
+
const timestamp = Date.now();
|
|
1434
|
+
const recovery: RunRecoveryEvent = {
|
|
1435
|
+
kind: "run_recovery",
|
|
1436
|
+
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
1437
|
+
runId: run.runId,
|
|
1438
|
+
status: "interrupted",
|
|
1439
|
+
branchGeneration: this.recoveryBranchGeneration,
|
|
1440
|
+
rotations: [],
|
|
1441
|
+
recoveryId: deriveRecoveryId({ runId: run.runId, rotations: [], timestamp }),
|
|
1442
|
+
timestamp,
|
|
1443
|
+
};
|
|
1444
|
+
this.persist(run.runId, "abort-recovery", () => this.write(recovery));
|
|
1445
|
+
if (!current()) return;
|
|
1446
|
+
run.status = "interrupted";
|
|
1447
|
+
run.updatedAt = timestamp;
|
|
1448
|
+
// A pending failed receipt can outlive execute(). In that case there is no
|
|
1449
|
+
// finally left to publish interruption or settle pre-existing waiters.
|
|
1450
|
+
if (!state.activeExecutionGenerations.has(generation)) {
|
|
1451
|
+
state.presentation.settle(run.status);
|
|
1452
|
+
this.notifyInspection(state);
|
|
1453
|
+
this.settleWaiters(run.runId, run);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1399
1457
|
private async execute(
|
|
1400
1458
|
state: ScriptRunState,
|
|
1401
1459
|
script: string,
|
|
@@ -1579,7 +1637,13 @@ export class WorkflowEngine {
|
|
|
1579
1637
|
const run = live?.run;
|
|
1580
1638
|
if (run && isSameBranch() && !isTerminalWorkflow(run.status)) {
|
|
1581
1639
|
this.setWorkflowStatus(run, "completed", undefined, undefined, result.result);
|
|
1582
|
-
|
|
1640
|
+
// Keep execution ownership until this newly accepted terminal fact has
|
|
1641
|
+
// its receipt too. Pause/stop may invalidate it while retries drain.
|
|
1642
|
+
await this.drainJournal(runId);
|
|
1643
|
+
if (!isCurrent() || executionSignal.aborted || run.status !== "completed")
|
|
1644
|
+
throw new WorkflowError("workflow execution aborted", WorkflowErrorCode.WORKFLOW_ABORTED, {
|
|
1645
|
+
recoverable: true,
|
|
1646
|
+
});
|
|
1583
1647
|
}
|
|
1584
1648
|
return result;
|
|
1585
1649
|
} catch (error: unknown) {
|
|
@@ -1593,20 +1657,7 @@ export class WorkflowEngine {
|
|
|
1593
1657
|
this.cleanupAgents(runId);
|
|
1594
1658
|
this.scheduleProviderResume(runId, workflowError.resetHint);
|
|
1595
1659
|
} else if (workflowError.code === WorkflowErrorCode.WORKFLOW_ABORTED && executionSignal.aborted) {
|
|
1596
|
-
this.
|
|
1597
|
-
this.write({
|
|
1598
|
-
kind: "run_recovery",
|
|
1599
|
-
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
1600
|
-
runId,
|
|
1601
|
-
status: "interrupted",
|
|
1602
|
-
branchGeneration: this.recoveryBranchGeneration,
|
|
1603
|
-
rotations: [],
|
|
1604
|
-
recoveryId: deriveRecoveryId({ runId, rotations: [], timestamp: Date.now() }),
|
|
1605
|
-
timestamp: Date.now(),
|
|
1606
|
-
}),
|
|
1607
|
-
);
|
|
1608
|
-
run.status = "interrupted";
|
|
1609
|
-
run.updatedAt = Date.now();
|
|
1660
|
+
this.recoverInterruptedExecution(state, generation, epoch);
|
|
1610
1661
|
} else {
|
|
1611
1662
|
this.cleanupAgents(runId);
|
|
1612
1663
|
this.setWorkflowStatus(run, "failed", message);
|
|
@@ -2382,6 +2433,7 @@ export class WorkflowEngine {
|
|
|
2382
2433
|
const epoch = this.branchEpoch;
|
|
2383
2434
|
if (!this.isCurrentState(state, generation, epoch)) return;
|
|
2384
2435
|
if (run.nonResumable && run.status === "stopped") return;
|
|
2436
|
+
state.pendingTerminal = undefined;
|
|
2385
2437
|
run.nonResumable = true;
|
|
2386
2438
|
const blockedNodeIds = Object.keys(run.callStatus).filter((nodeId) => run.callStatus[nodeId] !== "completed");
|
|
2387
2439
|
for (const nodeId of blockedNodeIds) run.callStatus[nodeId] = "stopped";
|
|
@@ -2573,9 +2625,9 @@ export class WorkflowEngine {
|
|
|
2573
2625
|
/**
|
|
2574
2626
|
* A Pi lifecycle event is delivered once. If its durable append is transiently
|
|
2575
2627
|
* unavailable, retain the mutation in memory and retry the append with
|
|
2576
|
-
* backoff without requiring the peer to re-emit.
|
|
2577
|
-
*
|
|
2578
|
-
*
|
|
2628
|
+
* backoff without requiring the peer to re-emit. Most in-memory facts can
|
|
2629
|
+
* lead the journal; terminal transitions instead publish inside their exact
|
|
2630
|
+
* append's receipt callback. Exhausted retries mark the journal blocked.
|
|
2579
2631
|
*/
|
|
2580
2632
|
private scheduleJournalRetry(runId: string, key: string, mutation: () => void): void {
|
|
2581
2633
|
// Every accepted fact has its own slot, including later retry generations
|
|
@@ -2660,10 +2712,8 @@ export class WorkflowEngine {
|
|
|
2660
2712
|
}
|
|
2661
2713
|
|
|
2662
2714
|
private write(event: JournalEvent): void {
|
|
2663
|
-
//
|
|
2664
|
-
//
|
|
2665
|
-
// append. Terminal bookkeeping already happened, so a retry that never
|
|
2666
|
-
// succeeds degrades to a warning rather than corrupting run state.
|
|
2715
|
+
// Only append here. persist turns rejection into a retry of this exact
|
|
2716
|
+
// fact; receipt-gated callers publish only after write returns successfully.
|
|
2667
2717
|
try {
|
|
2668
2718
|
this.journal.append(event);
|
|
2669
2719
|
} catch (error: unknown) {
|
|
@@ -2675,9 +2725,9 @@ export class WorkflowEngine {
|
|
|
2675
2725
|
}
|
|
2676
2726
|
|
|
2677
2727
|
/**
|
|
2678
|
-
* Persist one journal event with bounded retry. The caller
|
|
2679
|
-
*
|
|
2680
|
-
*
|
|
2728
|
+
* Persist one journal event with bounded retry. The caller owns in-memory
|
|
2729
|
+
* publication; terminal transitions do it inside mutation after write returns.
|
|
2730
|
+
* A transient failure schedules a retry of the exact same accepted append.
|
|
2681
2731
|
*/
|
|
2682
2732
|
private persist(runId: string, key: string, mutation: () => void): void {
|
|
2683
2733
|
const acceptedState = this.runs.get(runId);
|
|
@@ -2768,36 +2818,53 @@ export class WorkflowEngine {
|
|
|
2768
2818
|
const generation = state?.executionGeneration ?? 0;
|
|
2769
2819
|
const epoch = this.branchEpoch;
|
|
2770
2820
|
if (!state || state.run !== run || !this.isCurrentState(state, generation, epoch)) return;
|
|
2771
|
-
//
|
|
2772
|
-
// status
|
|
2821
|
+
// Capture once: retries must append the exact accepted transition, not a
|
|
2822
|
+
// newly timestamped status or a result still owned by the runtime.
|
|
2773
2823
|
const durableFinalResult = finalResult === undefined ? undefined : cloneWorkflowResult(finalResult);
|
|
2774
2824
|
const runFinalResult = finalResult === undefined ? undefined : cloneWorkflowResult(finalResult);
|
|
2775
2825
|
const timestamp = Date.now();
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2826
|
+
const event: JournalEvent = {
|
|
2827
|
+
kind: "workflow_transition",
|
|
2828
|
+
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
2829
|
+
runId: run.runId,
|
|
2830
|
+
status,
|
|
2831
|
+
...(error !== undefined ? { error } : {}),
|
|
2832
|
+
...(durableFinalResult !== undefined ? { finalResult: durableFinalResult } : {}),
|
|
2833
|
+
...(terminalIntent !== undefined ? { terminalIntent } : {}),
|
|
2834
|
+
timestamp,
|
|
2835
|
+
};
|
|
2836
|
+
const terminal = isTerminalWorkflow(status);
|
|
2837
|
+
const intent = terminal ? {} : undefined;
|
|
2838
|
+
// Stop/pause takes ownership synchronously, invalidating any queued success.
|
|
2839
|
+
state.pendingTerminal = intent;
|
|
2840
|
+
const current = () =>
|
|
2841
|
+
this.isCurrentState(state, generation, epoch) &&
|
|
2842
|
+
(!terminal || (state.pendingTerminal === intent && (status !== "completed" || !state.controller.signal.aborted)));
|
|
2843
|
+
const publish = () => {
|
|
2844
|
+
if (!current()) return;
|
|
2845
|
+
state.pendingTerminal = undefined;
|
|
2846
|
+
run.status = status;
|
|
2847
|
+
if (status === "running") run.error = undefined;
|
|
2848
|
+
if (error !== undefined) run.error = error;
|
|
2849
|
+
if (runFinalResult !== undefined) run.finalResult = runFinalResult;
|
|
2850
|
+
if (status === "completed") state.result = runFinalResult;
|
|
2851
|
+
if (terminalIntent !== undefined) run.terminalIntent = terminalIntent;
|
|
2852
|
+
run.updatedAt = timestamp;
|
|
2853
|
+
this.notifyInspection(state);
|
|
2854
|
+
if (!this.isCurrentState(state, generation, epoch)) return;
|
|
2855
|
+
if (terminal) {
|
|
2856
|
+
state.bufferedTerminals.clear();
|
|
2857
|
+
this.notifyTerminal(state);
|
|
2858
|
+
}
|
|
2859
|
+
};
|
|
2860
|
+
this.persist(run.runId, `transition:${status}`, () => {
|
|
2861
|
+
if (!current()) return;
|
|
2862
|
+
this.write(event);
|
|
2863
|
+
// write throws on append rejection inside persist, including its retries.
|
|
2864
|
+
// Only a receipt from this exact append may publish terminal state/result.
|
|
2865
|
+
if (terminal) publish();
|
|
2866
|
+
});
|
|
2867
|
+
if (!terminal) publish();
|
|
2801
2868
|
}
|
|
2802
2869
|
|
|
2803
2870
|
// ── restore / summary ──────────────────────────────────────────────────────
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
queryChildSessionContextImmediate,
|
|
18
18
|
} from "./rpc-client.js";
|
|
19
19
|
import { listSavedWorkflows, loadSavedWorkflow, saveWorkflow } from "./saved-workflows.js";
|
|
20
|
+
import { guardStatusMount, StatusActions, type StatusSurface, workflowStatusSummary } from "./status-actions.js";
|
|
20
21
|
import {
|
|
21
22
|
defaultStrengthTable,
|
|
22
23
|
isWorkflowStrength,
|
|
@@ -379,6 +380,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
379
380
|
let workflowUiBridge: WorkflowUiBridge | undefined;
|
|
380
381
|
let workflowUiSurfaceId = randomUUID();
|
|
381
382
|
let refreshQueued = false;
|
|
383
|
+
let statusActions: StatusActions | undefined;
|
|
384
|
+
let statusSource: (() => StatusSurface | undefined) | undefined;
|
|
385
|
+
const navigatorOpen = new Map<string, number>();
|
|
386
|
+
const retireStatusActions = (): void => {
|
|
387
|
+
const hadStatus = !!statusSource;
|
|
388
|
+
statusActions?.dispose();
|
|
389
|
+
statusActions = undefined;
|
|
390
|
+
statusSource = undefined;
|
|
391
|
+
if (hadStatus && deliveryContext?.mode === "tui") deliveryContext.ui.setStatus("pi-workflows", undefined);
|
|
392
|
+
};
|
|
382
393
|
|
|
383
394
|
const currentEngine = (): WorkflowEngine => {
|
|
384
395
|
if (!active || !engine || engine.isDisposed())
|
|
@@ -409,6 +420,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
409
420
|
return { cancel: true };
|
|
410
421
|
};
|
|
411
422
|
const invalidateDelivery = (): void => {
|
|
423
|
+
navigatorOpen.delete(workflowUiSurfaceId);
|
|
424
|
+
retireStatusActions();
|
|
412
425
|
workflowUiBridge?.retire(workflowUiSurfaceId);
|
|
413
426
|
workflowUiSurfaceId = randomUUID();
|
|
414
427
|
deliveryGeneration++;
|
|
@@ -482,6 +495,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
482
495
|
.list()
|
|
483
496
|
.map((summary) => workflowEngine.getRun(String(summary.runId)))
|
|
484
497
|
.filter((run): run is NonNullable<typeof run> => run !== undefined);
|
|
498
|
+
if (statusSource?.()) tui.setStatus("pi-workflows", workflowStatusSummary(runs));
|
|
485
499
|
const hasActive = runs.some((run) => !["completed", "failed", "stopped"].includes(run.status));
|
|
486
500
|
const surfaceId = workflowUiSurfaceId;
|
|
487
501
|
tui.setWidget(
|
|
@@ -578,7 +592,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
578
592
|
throw new Error("Provide either `script` or `name`.");
|
|
579
593
|
};
|
|
580
594
|
|
|
581
|
-
const showNavigator = async (ctx: ExtensionContext, initialRunId?: string): Promise<void> => {
|
|
595
|
+
const showNavigator = async (ctx: ExtensionContext, initialRunId?: string, guard?: () => void): Promise<void> => {
|
|
596
|
+
guard?.();
|
|
582
597
|
if (ctx.mode !== "tui" || !ctx.hasUI) {
|
|
583
598
|
await showWorkflowNavigator(ctx, currentEngine, { initialRunId });
|
|
584
599
|
return;
|
|
@@ -588,6 +603,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
588
603
|
const manager = ctx.sessionManager;
|
|
589
604
|
const sessionId = manager.getSessionId();
|
|
590
605
|
const resolve = (): WorkflowEngine => {
|
|
606
|
+
guard?.(); // Keep a mounted status navigator bound to its exact source.
|
|
591
607
|
if (
|
|
592
608
|
surfaceId !== workflowUiSurfaceId ||
|
|
593
609
|
selectedEngine !== currentEngine() ||
|
|
@@ -598,38 +614,104 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
598
614
|
throw new Error("Workflow inspector belongs to a retired session or branch.");
|
|
599
615
|
return selectedEngine;
|
|
600
616
|
};
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
617
|
+
navigatorOpen.set(surfaceId, (navigatorOpen.get(surfaceId) ?? 0) + 1);
|
|
618
|
+
try {
|
|
619
|
+
await showWorkflowNavigator(guard ? guardStatusMount(ctx, guard) : ctx, resolve, {
|
|
620
|
+
initialRunId,
|
|
621
|
+
getPresentation: (runId) => resolve().getInspection(runId),
|
|
622
|
+
openConversation: async (runId, callIndex) => {
|
|
623
|
+
const current = resolve();
|
|
624
|
+
if (!/^(?:0|[1-9]\d*)$/u.test(callIndex)) throw new Error("This call has no retained root conversation.");
|
|
625
|
+
const state = current.getState(runId);
|
|
626
|
+
const run = state?.run;
|
|
627
|
+
const agentId = run?.agentIds[callIndex];
|
|
628
|
+
const attempt = run?.attempts[callIndex];
|
|
629
|
+
if (!state || !run || !agentId || !Number.isInteger(attempt) || (attempt ?? 0) < 1)
|
|
630
|
+
throw new Error("No retained conversation is available for this call.");
|
|
631
|
+
const executionGeneration = state.executionGeneration;
|
|
632
|
+
await openOwnedConversation(
|
|
633
|
+
pi.events,
|
|
634
|
+
agentId,
|
|
635
|
+
{ extension: "pi-workflows", runId, nodeId: `call-${callIndex}`, attemptId: `attempt-${attempt}` },
|
|
636
|
+
() => {
|
|
637
|
+
try {
|
|
638
|
+
return (
|
|
639
|
+
resolve().getState(runId) === state &&
|
|
640
|
+
state.executionGeneration === executionGeneration &&
|
|
641
|
+
run.agentIds[callIndex] === agentId &&
|
|
642
|
+
run.attempts[callIndex] === attempt
|
|
643
|
+
);
|
|
644
|
+
} catch {
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
},
|
|
648
|
+
);
|
|
649
|
+
},
|
|
650
|
+
});
|
|
651
|
+
} finally {
|
|
652
|
+
// An obsolete factory cannot hold or release a replacement surface's slot.
|
|
653
|
+
const remaining = (navigatorOpen.get(surfaceId) ?? 0) - 1;
|
|
654
|
+
if (remaining > 0) navigatorOpen.set(surfaceId, remaining);
|
|
655
|
+
else navigatorOpen.delete(surfaceId);
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const registerStatusActions = (): void => {
|
|
660
|
+
const ctx = deliveryContext;
|
|
661
|
+
if (!active || !engine || ctx?.mode !== "tui" || !ctx.hasUI) return;
|
|
662
|
+
const selectedEngine = currentEngine();
|
|
663
|
+
const surfaceId = workflowUiSurfaceId;
|
|
664
|
+
const manager = ctx.sessionManager;
|
|
665
|
+
const sessionId = manager.getSessionId();
|
|
666
|
+
let branchIds = manager.getBranch().map((entry) => entry.id);
|
|
667
|
+
let retired = false;
|
|
668
|
+
const source = (): StatusSurface | undefined => {
|
|
669
|
+
if (retired) return;
|
|
670
|
+
let valid = false;
|
|
671
|
+
try {
|
|
672
|
+
const branch = manager.getBranch().map((entry) => entry.id);
|
|
673
|
+
valid =
|
|
674
|
+
active &&
|
|
675
|
+
engine === selectedEngine &&
|
|
676
|
+
!selectedEngine.isDisposed() &&
|
|
677
|
+
workflowUiSurfaceId === surfaceId &&
|
|
678
|
+
deliveryContext?.sessionManager === manager &&
|
|
679
|
+
manager.getSessionId() === sessionId &&
|
|
680
|
+
ctx.mode === "tui" &&
|
|
681
|
+
ctx.hasUI &&
|
|
682
|
+
ownsDeliveryBranch() &&
|
|
683
|
+
branchIds.every((id, index) => branch[index] === id);
|
|
684
|
+
if (valid) branchIds = branch;
|
|
685
|
+
} catch {
|
|
686
|
+
// A disposed context permanently retires this presentation surface.
|
|
687
|
+
}
|
|
688
|
+
if (!valid) {
|
|
689
|
+
retired = true;
|
|
690
|
+
if (statusSource === source) retireStatusActions();
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
return {
|
|
694
|
+
owner: selectedEngine,
|
|
695
|
+
surfaceId,
|
|
696
|
+
sessionId,
|
|
697
|
+
busy: () => (navigatorOpen.get(surfaceId) ?? 0) > 0,
|
|
698
|
+
open: () =>
|
|
699
|
+
showNavigator(ctx, undefined, () => {
|
|
700
|
+
if (!source()) throw new Error("Workflow status surface is retired.");
|
|
701
|
+
}),
|
|
702
|
+
};
|
|
703
|
+
};
|
|
704
|
+
statusSource = source;
|
|
705
|
+
statusActions = new StatusActions(
|
|
706
|
+
pi.events,
|
|
707
|
+
{ providerId: "pi-workflows", statusKey: "pi-workflows", actionId: "runs", label: "Workflow runs" },
|
|
708
|
+
source,
|
|
709
|
+
);
|
|
710
|
+
if (source())
|
|
711
|
+
ctx.ui.setStatus(
|
|
712
|
+
"pi-workflows",
|
|
713
|
+
workflowStatusSummary(selectedEngine.list().map((run) => ({ status: String(run.status) }))),
|
|
714
|
+
);
|
|
633
715
|
};
|
|
634
716
|
|
|
635
717
|
const workflowUiSurface = (): WorkflowUiSurface | undefined => {
|
|
@@ -1452,6 +1534,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1452
1534
|
workflowEngine.resumeLifecycle();
|
|
1453
1535
|
deliveryContext = ctx;
|
|
1454
1536
|
deliveryBranchAnchor = journalBranchAnchor;
|
|
1537
|
+
registerStatusActions();
|
|
1455
1538
|
hydratePendingDeliveries(entries);
|
|
1456
1539
|
schedulePendingDeliveries();
|
|
1457
1540
|
} catch (error: unknown) {
|
|
@@ -1799,6 +1882,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1799
1882
|
// ownership, not a captured function value, provide session affinity.
|
|
1800
1883
|
deliveryContext = ctx;
|
|
1801
1884
|
deliveryBranchAnchor = journalBranchAnchor;
|
|
1885
|
+
registerStatusActions();
|
|
1802
1886
|
workflowUiBridge ??= new WorkflowUiBridge(pi.events, workflowUiSurface);
|
|
1803
1887
|
workflowUiBridge.publish();
|
|
1804
1888
|
inputPreparing = false;
|
package/src/runtime.ts
CHANGED
|
@@ -1700,29 +1700,34 @@ export async function runWorkflow<T = unknown>(
|
|
|
1700
1700
|
// Reserve a real dispatch synchronously — no await occurs between this
|
|
1701
1701
|
// check and the increments, so a parallel() burst cannot overshoot the cap.
|
|
1702
1702
|
if (shared.physicalAgentCount >= maxAgents) throw agentLimitError();
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1703
|
+
// Capture this frame's phase allowance at lexical admission. A later phase
|
|
1704
|
+
// change/rebase must not donate a fresh allowance to queued work or retries.
|
|
1705
|
+
const phaseBudget = assignedPhase ? state.phaseBudgets.get(assignedPhase) : undefined;
|
|
1706
|
+
const checkBudget = () => {
|
|
1707
|
+
if (budget.total !== null && budget.remaining() <= 0) {
|
|
1708
|
+
throw new WorkflowError("workflow token budget exhausted", WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED, {
|
|
1709
|
+
recoverable: false,
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
// Soft gate: completed spend is shared, but active calls reserve no tokens.
|
|
1713
|
+
if (phaseBudget) {
|
|
1714
|
+
const phaseSpent = shared.spent - phaseBudget.startSpent;
|
|
1715
|
+
if (phaseSpent >= phaseBudget.budget) {
|
|
1714
1716
|
throw new WorkflowError(
|
|
1715
|
-
`phase "${assignedPhase}" token sub-budget exhausted (${
|
|
1717
|
+
`phase "${assignedPhase}" token sub-budget exhausted (${phaseBudget.budget})`,
|
|
1716
1718
|
WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED,
|
|
1717
1719
|
{ recoverable: false },
|
|
1718
1720
|
);
|
|
1719
1721
|
}
|
|
1720
|
-
if (!
|
|
1721
|
-
|
|
1722
|
-
log(
|
|
1722
|
+
if (!phaseBudget.warned && phaseSpent >= phaseBudget.budget * 0.8) {
|
|
1723
|
+
phaseBudget.warned = true;
|
|
1724
|
+
log(
|
|
1725
|
+
`phase "${assignedPhase}" at ${Math.round((phaseSpent / phaseBudget.budget) * 100)}% of its token sub-budget`,
|
|
1726
|
+
);
|
|
1723
1727
|
}
|
|
1724
1728
|
}
|
|
1725
|
-
}
|
|
1729
|
+
};
|
|
1730
|
+
checkBudget();
|
|
1726
1731
|
shared.agentCount++;
|
|
1727
1732
|
frameAgentCount++;
|
|
1728
1733
|
shared.physicalAgentCount++;
|
|
@@ -1737,6 +1742,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1737
1742
|
await Promise.resolve();
|
|
1738
1743
|
throwIfAborted();
|
|
1739
1744
|
if (batch?.cancelled) throw agentLimitError();
|
|
1745
|
+
checkBudget();
|
|
1740
1746
|
|
|
1741
1747
|
const timeout =
|
|
1742
1748
|
agentOptions.timeoutMs !== undefined ? normalizeAgentTimeout(agentOptions.timeoutMs) : agentTimeoutMs;
|
|
@@ -1787,6 +1793,9 @@ export async function runWorkflow<T = unknown>(
|
|
|
1787
1793
|
|
|
1788
1794
|
try {
|
|
1789
1795
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1796
|
+
// Outside the attempt catch: a rejected admission is not a provider
|
|
1797
|
+
// attempt and must not be charged an estimated failure or retried.
|
|
1798
|
+
checkBudget();
|
|
1790
1799
|
usage = undefined;
|
|
1791
1800
|
const externalSignal = options.signal;
|
|
1792
1801
|
let onExternalAbort: (() => void) | undefined;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export const STATUS_QUERY = "pi-ui:status-actions:v1:query";
|
|
4
|
+
export const STATUS_OPEN = "pi-ui:status-actions:v1:open";
|
|
5
|
+
const FAILURE = "Status action is unavailable.";
|
|
6
|
+
|
|
7
|
+
export interface StatusSurface {
|
|
8
|
+
surfaceId: string;
|
|
9
|
+
sessionId: string;
|
|
10
|
+
/** Private identity, never placed on the wire. */
|
|
11
|
+
owner: object;
|
|
12
|
+
busy(): boolean;
|
|
13
|
+
open(): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Inspect descriptors, never values on an untrusted object (including getters). */
|
|
17
|
+
function request(raw: unknown, keys: readonly string[]): Record<string, unknown> | undefined {
|
|
18
|
+
try {
|
|
19
|
+
if (!raw || typeof raw !== "object") return;
|
|
20
|
+
const prototype = Object.getPrototypeOf(raw);
|
|
21
|
+
if (prototype !== Object.prototype && prototype !== null) return;
|
|
22
|
+
const descriptors = Object.getOwnPropertyDescriptors(raw);
|
|
23
|
+
if (Reflect.ownKeys(descriptors).length !== keys.length) return;
|
|
24
|
+
const values: Record<string, unknown> = Object.create(null);
|
|
25
|
+
for (const key of keys) {
|
|
26
|
+
const descriptor = descriptors[key];
|
|
27
|
+
if (!descriptor || !("value" in descriptor)) return;
|
|
28
|
+
const value: unknown = descriptor.value;
|
|
29
|
+
if (key === "version" ? value !== 1 : typeof value !== "string" || value.length < 1 || value.length > 128) return;
|
|
30
|
+
values[key] = value;
|
|
31
|
+
}
|
|
32
|
+
return values;
|
|
33
|
+
} catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Optional presentation-only endpoint; the source must fail closed on live divergence. */
|
|
39
|
+
export class StatusActions {
|
|
40
|
+
private readonly unsubscribe: Array<() => void>;
|
|
41
|
+
private disposed = false;
|
|
42
|
+
private opening = false;
|
|
43
|
+
private readonly admitted = new Set<string>();
|
|
44
|
+
|
|
45
|
+
constructor(
|
|
46
|
+
private readonly events: ExtensionAPI["events"],
|
|
47
|
+
private readonly descriptor: { providerId: string; statusKey: string; actionId: string; label: string },
|
|
48
|
+
private readonly source: () => StatusSurface | undefined,
|
|
49
|
+
) {
|
|
50
|
+
this.unsubscribe = [
|
|
51
|
+
events.on(STATUS_QUERY, (raw: unknown) => this.query(raw)),
|
|
52
|
+
events.on(STATUS_OPEN, (raw: unknown) => this.open(raw)),
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
dispose(): void {
|
|
57
|
+
if (this.disposed) return;
|
|
58
|
+
this.disposed = true;
|
|
59
|
+
for (const unsubscribe of this.unsubscribe) unsubscribe();
|
|
60
|
+
this.admitted.clear();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private query(raw: unknown): void {
|
|
64
|
+
if (this.disposed) return;
|
|
65
|
+
const parsed = request(raw, ["version", "requestId", "sessionId"]);
|
|
66
|
+
if (!parsed) return;
|
|
67
|
+
const surface = this.source();
|
|
68
|
+
if (!surface || parsed.sessionId !== surface.sessionId || this.disposed) return;
|
|
69
|
+
this.events.emit(`${STATUS_QUERY}:reply:${parsed.requestId}`, {
|
|
70
|
+
success: true,
|
|
71
|
+
data: {
|
|
72
|
+
version: 1,
|
|
73
|
+
providerId: this.descriptor.providerId,
|
|
74
|
+
surfaceId: surface.surfaceId,
|
|
75
|
+
sessionId: surface.sessionId,
|
|
76
|
+
entries: [
|
|
77
|
+
{ statusKey: this.descriptor.statusKey, actionId: this.descriptor.actionId, label: this.descriptor.label },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private open(raw: unknown): void {
|
|
84
|
+
if (this.disposed) return;
|
|
85
|
+
const parsed = request(raw, ["version", "requestId", "providerId", "surfaceId", "sessionId", "actionId"]);
|
|
86
|
+
if (!parsed || parsed.providerId !== this.descriptor.providerId) return;
|
|
87
|
+
const surface = this.source();
|
|
88
|
+
if (!surface || this.disposed || parsed.sessionId !== surface.sessionId || parsed.surfaceId !== surface.surfaceId)
|
|
89
|
+
return; // Other owners, including duplicate providers, may share this bus.
|
|
90
|
+
const channel = `${STATUS_OPEN}:reply:${parsed.requestId}`;
|
|
91
|
+
const id = String(parsed.requestId);
|
|
92
|
+
if (parsed.actionId !== this.descriptor.actionId || this.opening || surface.busy() || this.admitted.has(id)) {
|
|
93
|
+
this.events.emit(channel, { success: false, error: FAILURE });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
this.admitted.add(id);
|
|
97
|
+
if (this.admitted.size > 256) this.admitted.delete(this.admitted.values().next().value as string);
|
|
98
|
+
this.opening = true;
|
|
99
|
+
// Admission is synchronous; the selector can relinquish focus before mount.
|
|
100
|
+
this.events.emit(channel, { success: true, data: { accepted: true } });
|
|
101
|
+
void Promise.resolve()
|
|
102
|
+
.then(() => {
|
|
103
|
+
if (this.disposed) return;
|
|
104
|
+
const current = this.source();
|
|
105
|
+
if (
|
|
106
|
+
this.disposed ||
|
|
107
|
+
!current ||
|
|
108
|
+
current.owner !== surface.owner ||
|
|
109
|
+
current.surfaceId !== surface.surfaceId ||
|
|
110
|
+
current.sessionId !== surface.sessionId
|
|
111
|
+
)
|
|
112
|
+
return;
|
|
113
|
+
return surface.open();
|
|
114
|
+
})
|
|
115
|
+
.catch(() => {
|
|
116
|
+
// Admission does not promise successful mount. Never redirect to another owner.
|
|
117
|
+
})
|
|
118
|
+
.finally(() => {
|
|
119
|
+
this.opening = false;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The host may defer a custom factory after admission; fence that boundary too. */
|
|
125
|
+
export function guardStatusMount(ctx: ExtensionContext, guard: () => void): ExtensionContext {
|
|
126
|
+
const custom: ExtensionContext["ui"]["custom"] = (factory, options) =>
|
|
127
|
+
ctx.ui.custom((...args) => {
|
|
128
|
+
guard();
|
|
129
|
+
return factory(...args);
|
|
130
|
+
}, options);
|
|
131
|
+
return { ...ctx, ui: { ...ctx.ui, custom } };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Count run authority only; child completion cannot establish run completion. */
|
|
135
|
+
export function workflowStatusSummary(runs: ReadonlyArray<{ status: string }>): string | undefined {
|
|
136
|
+
if (!runs.length) return undefined;
|
|
137
|
+
const count = (statuses: string[]) => runs.filter((run) => statuses.includes(run.status)).length;
|
|
138
|
+
const active = count(["pending", "running", "pausing", "stopping"]);
|
|
139
|
+
const paused = count(["paused", "interrupted"]);
|
|
140
|
+
return active || paused
|
|
141
|
+
? `Workflows · A${active} P${paused}`
|
|
142
|
+
: `Workflows · C${count(["completed"])} F${count(["failed"])} S${count(["stopped"])}`;
|
|
143
|
+
}
|