@wichayutdew/pi-workflows 0.3.0 → 1.0.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/README.md +55 -39
- package/dist/index.js +5771 -4794
- package/examples/mr-comments.workflow.yaml +2 -0
- package/package.json +1 -1
- package/schemas/workflow.schema.json +10 -1
- package/src/command-names.ts +7 -1
- package/src/commands.ts +126 -52
- package/src/config/catalog.ts +162 -0
- package/src/config/ceiling.ts +120 -116
- package/src/config/command-conflicts.ts +4 -4
- package/src/config/diagnostics.ts +28 -0
- package/src/config/load-settings.ts +37 -0
- package/src/config/load-types.ts +53 -0
- package/src/config/load-workflows.ts +135 -0
- package/src/config/load.ts +43 -311
- package/src/config/types.ts +150 -137
- package/src/config/validate.ts +12 -1261
- package/src/config/validation/permissions.ts +312 -0
- package/src/config/validation/prompt.ts +21 -0
- package/src/config/validation/settings.ts +122 -0
- package/src/config/validation/shared.ts +129 -0
- package/src/config/validation/shortcut.ts +118 -0
- package/src/config/validation/step.ts +228 -0
- package/src/config/validation/subagent.ts +288 -0
- package/src/config/validation/workflow.ts +156 -0
- package/src/config/yaml.ts +29 -0
- package/src/digest.ts +38 -6
- package/src/engine/checkpoint.ts +13 -9
- package/src/engine/create-run.ts +43 -0
- package/src/engine/gate-transitions.ts +187 -0
- package/src/engine/reconciliation-history.ts +88 -0
- package/src/engine/resume.ts +19 -7
- package/src/engine/run-advance.ts +122 -0
- package/src/engine/run-lifecycle.ts +124 -0
- package/src/engine/run-reconciliation.ts +116 -0
- package/src/engine/run-validation.ts +129 -0
- package/src/engine/state-types.ts +66 -0
- package/src/engine/state.ts +10 -193
- package/src/engine/transition-helpers.ts +28 -0
- package/src/engine/transition-types.ts +8 -0
- package/src/engine/transitions.ts +17 -471
- package/src/harness/action-context.ts +181 -0
- package/src/harness/catalog.ts +45 -0
- package/src/harness/context-idle.ts +21 -0
- package/src/harness/core-actions.ts +299 -0
- package/src/harness/delegation-control-actions.ts +250 -0
- package/src/harness/delegation-failure.ts +215 -0
- package/src/harness/delegation-plan.ts +219 -0
- package/src/harness/delegation-recovery-validation.ts +159 -0
- package/src/harness/delegation-response-actions.ts +295 -0
- package/src/harness/delegation-retry-policy.ts +118 -0
- package/src/harness/dependencies.ts +165 -0
- package/src/harness/gate-submission-action.ts +154 -0
- package/src/harness/lifecycle-actions.ts +154 -0
- package/src/harness/pause-actions.ts +120 -0
- package/src/harness/plannotator-result-actions.ts +123 -0
- package/src/harness/prompt-gate-actions.ts +270 -0
- package/src/harness/resume-action.ts +261 -0
- package/src/harness/start-actions.ts +183 -0
- package/src/harness/status-actions.ts +191 -0
- package/src/harness/step-execution-actions.ts +283 -0
- package/src/harness/types.ts +76 -0
- package/src/harness.ts +236 -2303
- package/src/index.ts +73 -11
- package/src/integrations/plannotator-requests.ts +127 -0
- package/src/integrations/plannotator-responses.ts +141 -0
- package/src/integrations/plannotator-types.ts +44 -0
- package/src/integrations/plannotator.ts +16 -235
- package/src/integrations/prompt-gate.ts +17 -10
- package/src/integrations/subagents/child-policy-envelope.ts +119 -0
- package/src/integrations/subagents/child-policy-paths.ts +66 -0
- package/src/integrations/subagents/child-policy-sections.ts +186 -0
- package/src/integrations/subagents/child-policy-types.ts +32 -0
- package/src/integrations/subagents/child-policy-validation.ts +163 -0
- package/src/integrations/subagents/child-runtime-completion.ts +56 -0
- package/src/integrations/subagents/child-runtime-dependencies.ts +47 -0
- package/src/integrations/subagents/child-runtime-files.ts +156 -0
- package/src/integrations/subagents/child-runtime-policy.ts +83 -0
- package/src/integrations/subagents/child-runtime-types.ts +30 -0
- package/src/integrations/subagents/child-runtime.ts +140 -301
- package/src/integrations/subagents/client-delegation.ts +181 -0
- package/src/integrations/subagents/client-messages.ts +66 -0
- package/src/integrations/subagents/client-types.ts +36 -0
- package/src/integrations/subagents/client.ts +127 -219
- package/src/integrations/subagents/delegated-result.ts +31 -0
- package/src/integrations/subagents/diagnostic-format.ts +40 -0
- package/src/integrations/subagents/diagnostic-text.ts +114 -0
- package/src/integrations/subagents/diagnostic-types.ts +77 -0
- package/src/integrations/subagents/diagnostics.ts +24 -977
- package/src/integrations/subagents/failure-correlation.ts +235 -0
- package/src/integrations/subagents/failure-transcript.ts +192 -0
- package/src/integrations/subagents/hidden-bash-failure.ts +98 -0
- package/src/integrations/subagents/protocol-events.ts +27 -0
- package/src/integrations/subagents/protocol.ts +30 -375
- package/src/integrations/subagents/replay-audit.ts +147 -0
- package/src/integrations/subagents/replay-safety.ts +82 -0
- package/src/integrations/subagents/session-diagnostics.ts +258 -0
- package/src/policy/approved-command-extraction.ts +71 -0
- package/src/policy/approved-commands.ts +15 -432
- package/src/policy/bash-authorization.ts +86 -0
- package/src/policy/bash-read-only.ts +161 -0
- package/src/policy/bash-types.ts +20 -0
- package/src/policy/bash.ts +4 -346
- package/src/policy/completion-batch.ts +21 -16
- package/src/policy/immutable-input.ts +5 -2
- package/src/policy/mcp-authorization.ts +71 -0
- package/src/policy/restricted-command.ts +141 -0
- package/src/policy/restricted-git.ts +30 -0
- package/src/policy/reviewed-artifact.ts +97 -0
- package/src/policy/reviewed-command-safety.ts +150 -0
- package/src/policy/reviewed-command-shape.ts +49 -0
- package/src/policy/reviewed-repository-contract.ts +138 -0
- package/src/policy/reviewed-repository-cwd.ts +102 -0
- package/src/policy/tool-call-authorization.ts +58 -0
- package/src/policy/tool-selection.ts +72 -0
- package/src/policy/tool-types.ts +14 -0
- package/src/policy/tools.ts +11 -150
- package/src/preflight.ts +96 -61
- package/src/prompt/main-workflow-notice.ts +41 -0
- package/src/prompt/retry-task.ts +83 -0
- package/src/prompt/step-contract.ts +101 -0
- package/src/prompt/step-sections.ts +75 -0
- package/src/prompt/step-task.ts +156 -0
- package/src/prompt/template.ts +83 -0
- package/src/prompt.ts +10 -254
- package/src/runtime/completion-tool.ts +3 -0
- package/src/runtime/main-step-completion.ts +78 -0
- package/src/runtime/main-step-lifecycle.ts +37 -0
- package/src/runtime/main-step-policy.ts +84 -0
- package/src/runtime/main-step-runtime-types.ts +64 -0
- package/src/runtime/main-step-runtime.ts +129 -200
- package/src/runtime/main-step-state.ts +85 -0
- package/src/runtime/serial-task-queue.ts +48 -13
- package/src/runtime/step-result.ts +48 -27
- package/src/workflow-list.ts +15 -6
- package/src/workflow-status/format-status.ts +52 -0
- package/src/workflow-status/formatting.ts +178 -0
- package/src/workflow-status/layout.ts +109 -0
- package/src/workflow-status/render-board.ts +101 -0
- package/src/workflow-status/render-path.ts +123 -0
- package/src/workflow-status/render-summary.ts +144 -0
- package/src/workflow-status/types.ts +40 -0
- package/src/workflow-status/view.ts +273 -0
- package/src/workflow-status.ts +20 -820
|
@@ -1,43 +1,62 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
const MAX_ARTIFACT_CHARS = 200_000;
|
|
2
|
+
const RESULT_KEYS = new Set([
|
|
3
|
+
'version',
|
|
4
|
+
'policyDigest',
|
|
5
|
+
'outcome',
|
|
6
|
+
'summary',
|
|
7
|
+
'artifact',
|
|
8
|
+
]);
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Result constraints derived from the active workflow step.
|
|
12
|
+
*/
|
|
13
|
+
export type StepResultPolicy = {
|
|
14
|
+
readonly policyDigest: string;
|
|
15
|
+
readonly outcomes: ReadonlyArray<string>;
|
|
16
|
+
readonly summaryMaxChars: number;
|
|
17
|
+
readonly gateSubmitOutcome?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validated result handed back to the workflow engine.
|
|
22
|
+
*/
|
|
23
|
+
export type WorkflowStepResult = {
|
|
24
|
+
readonly version: 1;
|
|
25
|
+
readonly policyDigest: string;
|
|
26
|
+
readonly outcome: string;
|
|
27
|
+
readonly summary: string;
|
|
28
|
+
readonly artifact?: string;
|
|
29
|
+
};
|
|
15
30
|
|
|
16
|
-
|
|
31
|
+
const isObject = (value: unknown): value is Record<string, unknown> => {
|
|
17
32
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
18
|
-
}
|
|
33
|
+
};
|
|
19
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Validates and normalizes the structured result returned by a workflow step.
|
|
37
|
+
*
|
|
38
|
+
* @param value - Untrusted structured output from the agent.
|
|
39
|
+
* @param policy - Result constraints for the active workflow step.
|
|
40
|
+
* @returns A normalized workflow-step result.
|
|
41
|
+
* @throws When the result violates the active policy or result schema.
|
|
42
|
+
*/
|
|
20
43
|
export function parseWorkflowStepResult(
|
|
21
44
|
value: unknown,
|
|
22
45
|
policy: StepResultPolicy,
|
|
23
46
|
): WorkflowStepResult {
|
|
24
|
-
if (!isObject(value))
|
|
47
|
+
if (!isObject(value)) {
|
|
25
48
|
throw new Error('workflow step result must be an object');
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
'outcome',
|
|
30
|
-
'summary',
|
|
31
|
-
'artifact',
|
|
32
|
-
]);
|
|
33
|
-
const unknownKey = Object.keys(value).find((key) => !allowedKeys.has(key));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const unknownKey = Object.keys(value).find((key) => !RESULT_KEYS.has(key));
|
|
34
52
|
if (unknownKey) {
|
|
35
53
|
throw new Error(
|
|
36
54
|
`workflow step result has unknown property "${unknownKey}"`,
|
|
37
55
|
);
|
|
38
56
|
}
|
|
39
|
-
if (value.version !== 1)
|
|
57
|
+
if (value.version !== 1) {
|
|
40
58
|
throw new Error('unsupported workflow step result version');
|
|
59
|
+
}
|
|
41
60
|
if (value.policyDigest !== policy.policyDigest) {
|
|
42
61
|
throw new Error('workflow step result does not match the active policy');
|
|
43
62
|
}
|
|
@@ -66,8 +85,10 @@ export function parseWorkflowStepResult(
|
|
|
66
85
|
}
|
|
67
86
|
const artifact =
|
|
68
87
|
typeof value.artifact === 'string' ? value.artifact : undefined;
|
|
69
|
-
if (artifact !== undefined && artifact.length >
|
|
70
|
-
throw new Error(
|
|
88
|
+
if (artifact !== undefined && artifact.length > MAX_ARTIFACT_CHARS) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`workflow step artifact exceeds ${MAX_ARTIFACT_CHARS} characters`,
|
|
91
|
+
);
|
|
71
92
|
}
|
|
72
93
|
if (
|
|
73
94
|
value.outcome === policy.gateSubmitOutcome &&
|
package/src/workflow-list.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Display fields needed for one workflow-list row.
|
|
3
|
+
*/
|
|
4
|
+
export type WorkflowListItem = {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly command: string;
|
|
7
|
+
readonly description: string;
|
|
8
|
+
};
|
|
6
9
|
|
|
7
10
|
function escapeMarkdownTableCell(value: string): string {
|
|
8
11
|
return value
|
|
@@ -11,8 +14,14 @@ function escapeMarkdownTableCell(value: string): string {
|
|
|
11
14
|
.replace(/\r\n|\r|\n/g, ' ');
|
|
12
15
|
}
|
|
13
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Formats loaded workflows as a Markdown table for the Pi transcript.
|
|
19
|
+
*
|
|
20
|
+
* @param workflows - Workflow identities and descriptions to list.
|
|
21
|
+
* @returns A Markdown workflow table.
|
|
22
|
+
*/
|
|
14
23
|
export function formatWorkflowList(
|
|
15
|
-
workflows:
|
|
24
|
+
workflows: ReadonlyArray<WorkflowListItem>,
|
|
16
25
|
): string {
|
|
17
26
|
return [
|
|
18
27
|
'| Workflow | Command | Description |',
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { DEFAULT_STATUS_SHORTCUT } from '../config/types.ts';
|
|
2
|
+
import { formatShortcutLabel } from './formatting.ts';
|
|
3
|
+
import { renderBoard } from './render-board.ts';
|
|
4
|
+
import type { WorkflowStatusSnapshot, WorkflowStatusTheme } from './types.ts';
|
|
5
|
+
|
|
6
|
+
const UNSTYLED_THEME = {
|
|
7
|
+
fg: (_color: string, value: string) => value,
|
|
8
|
+
bg: (_color: string, value: string) => value,
|
|
9
|
+
bold: (value: string) => value,
|
|
10
|
+
} as const satisfies WorkflowStatusTheme;
|
|
11
|
+
|
|
12
|
+
/** Format a plain-text workflow status suitable for fallback notifications. */
|
|
13
|
+
export function formatWorkflowStatusText(
|
|
14
|
+
snapshot: WorkflowStatusSnapshot,
|
|
15
|
+
): string {
|
|
16
|
+
const { run } = snapshot;
|
|
17
|
+
const lines = [
|
|
18
|
+
`Workflow: ${run.workflowId}`,
|
|
19
|
+
`Run: ${run.runId}`,
|
|
20
|
+
`Status: ${run.status}`,
|
|
21
|
+
`Step: ${run.currentStepId}`,
|
|
22
|
+
`Completed steps: ${run.history.length}`,
|
|
23
|
+
];
|
|
24
|
+
if (run.pendingGate?.reviewId) {
|
|
25
|
+
lines.push(`Review: ${run.pendingGate.reviewId}`);
|
|
26
|
+
}
|
|
27
|
+
if (snapshot.execution?.kind === 'subagent') {
|
|
28
|
+
lines.push(
|
|
29
|
+
`Subagent: ${snapshot.execution.agent} (${snapshot.execution.requestId})`,
|
|
30
|
+
`Progress: ${snapshot.execution.progress}`,
|
|
31
|
+
);
|
|
32
|
+
} else if (snapshot.execution?.kind === 'main') {
|
|
33
|
+
lines.push('Execution: main agent');
|
|
34
|
+
}
|
|
35
|
+
if (run.pauseReason) lines.push(`Reason: ${run.pauseReason}`);
|
|
36
|
+
return lines.join('\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Format the full workflow status board at a requested visible width. */
|
|
40
|
+
export function formatWorkflowStatusBoard(
|
|
41
|
+
snapshot: WorkflowStatusSnapshot,
|
|
42
|
+
width = 88,
|
|
43
|
+
theme: WorkflowStatusTheme = UNSTYLED_THEME,
|
|
44
|
+
): Array<string> {
|
|
45
|
+
return renderBoard(
|
|
46
|
+
theme,
|
|
47
|
+
snapshot,
|
|
48
|
+
Math.max(8, Math.floor(width)),
|
|
49
|
+
false,
|
|
50
|
+
formatShortcutLabel(DEFAULT_STATUS_SHORTCUT),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { KeyId } from '@earendil-works/pi-tui';
|
|
2
|
+
import type { LoadedWorkflow } from '../config/types.ts';
|
|
3
|
+
import type { WorkflowRun } from '../engine/state.ts';
|
|
4
|
+
import type {
|
|
5
|
+
StepDisplayStatus,
|
|
6
|
+
WorkflowStatusExecution,
|
|
7
|
+
WorkflowStatusSnapshot,
|
|
8
|
+
WorkflowStatusTheme,
|
|
9
|
+
WorkflowStatusThemeColor,
|
|
10
|
+
} from './types.ts';
|
|
11
|
+
|
|
12
|
+
const WORKING_ICON_FRAME_MS = 250;
|
|
13
|
+
const WORKING_ICON_FRAMES = ['◐', '◓', '◑', '◒'] as const;
|
|
14
|
+
|
|
15
|
+
const SHORTCUT_LABELS: ReadonlyMap<string, string> = new Map([
|
|
16
|
+
['ctrl', 'Ctrl'],
|
|
17
|
+
['shift', 'Shift'],
|
|
18
|
+
['alt', 'Alt'],
|
|
19
|
+
['super', 'Super'],
|
|
20
|
+
['escape', 'Esc'],
|
|
21
|
+
['esc', 'Esc'],
|
|
22
|
+
['enter', 'Enter'],
|
|
23
|
+
['return', 'Enter'],
|
|
24
|
+
['tab', 'Tab'],
|
|
25
|
+
['space', 'Space'],
|
|
26
|
+
['backspace', 'Backspace'],
|
|
27
|
+
['delete', 'Del'],
|
|
28
|
+
['insert', 'Ins'],
|
|
29
|
+
['clear', 'Clear'],
|
|
30
|
+
['home', 'Home'],
|
|
31
|
+
['end', 'End'],
|
|
32
|
+
['pageUp', 'PgUp'],
|
|
33
|
+
['pageDown', 'PgDn'],
|
|
34
|
+
['up', 'Up'],
|
|
35
|
+
['down', 'Down'],
|
|
36
|
+
['left', 'Left'],
|
|
37
|
+
['right', 'Right'],
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
function workingIcon(now: number): string {
|
|
41
|
+
const frame =
|
|
42
|
+
WORKING_ICON_FRAMES[
|
|
43
|
+
Math.floor(now / WORKING_ICON_FRAME_MS) % WORKING_ICON_FRAMES.length
|
|
44
|
+
];
|
|
45
|
+
return frame ?? WORKING_ICON_FRAMES[0];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Format a Pi key identifier for display in status help text. */
|
|
49
|
+
export function formatShortcutLabel(shortcut: KeyId): string {
|
|
50
|
+
return shortcut
|
|
51
|
+
.split('+')
|
|
52
|
+
.map((part) => {
|
|
53
|
+
const label = SHORTCUT_LABELS.get(part);
|
|
54
|
+
if (label) return label;
|
|
55
|
+
if (/^f\d+$/.test(part)) return part.toUpperCase();
|
|
56
|
+
return part.length === 1 ? part.toUpperCase() : part;
|
|
57
|
+
})
|
|
58
|
+
.join('+');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Render the colored glyph for one workflow step display state. */
|
|
62
|
+
export function statusGlyph(
|
|
63
|
+
theme: WorkflowStatusTheme,
|
|
64
|
+
status: StepDisplayStatus,
|
|
65
|
+
now: number,
|
|
66
|
+
): string {
|
|
67
|
+
if (status === 'completed') return theme.fg('success', '✓');
|
|
68
|
+
if (status === 'running') return theme.fg('accent', workingIcon(now));
|
|
69
|
+
if (status === 'paused' || status === 'awaiting-gate') {
|
|
70
|
+
return theme.fg('warning', '◆');
|
|
71
|
+
}
|
|
72
|
+
return theme.fg('error', '✕');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Select the theme color associated with a workflow display state. */
|
|
76
|
+
export function statusColor(
|
|
77
|
+
status: StepDisplayStatus,
|
|
78
|
+
): WorkflowStatusThemeColor {
|
|
79
|
+
if (status === 'completed') return 'success';
|
|
80
|
+
if (status === 'running') return 'accent';
|
|
81
|
+
if (status === 'paused' || status === 'awaiting-gate') return 'warning';
|
|
82
|
+
return 'error';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Format a workflow display state as an uppercase human label. */
|
|
86
|
+
export function statusLabel(status: StepDisplayStatus): string {
|
|
87
|
+
return status === 'awaiting-gate'
|
|
88
|
+
? 'AWAITING REVIEW'
|
|
89
|
+
: status.toUpperCase().replaceAll('-', ' ');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Render a colored, bracketed workflow status badge. */
|
|
93
|
+
export function statusBadge(
|
|
94
|
+
theme: WorkflowStatusTheme,
|
|
95
|
+
status: StepDisplayStatus,
|
|
96
|
+
): string {
|
|
97
|
+
return theme.fg(statusColor(status), theme.bold(`[${statusLabel(status)}]`));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Derive the visible status, including a paused current-step failure. */
|
|
101
|
+
export function runDisplayStatus(run: WorkflowRun): StepDisplayStatus {
|
|
102
|
+
return run.status === 'paused' && run.failedStepId === run.currentStepId
|
|
103
|
+
? 'failed'
|
|
104
|
+
: run.status;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Return the compact icon used by the persistent workflow status line. */
|
|
108
|
+
export function workflowStatusIcon(run: WorkflowRun, now = Date.now()): string {
|
|
109
|
+
const status = runDisplayStatus(run);
|
|
110
|
+
if (status === 'completed') return '✓';
|
|
111
|
+
if (status === 'running') return workingIcon(now);
|
|
112
|
+
if (status === 'failed' || status === 'aborted') return '✕';
|
|
113
|
+
return '◆';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Resolve and normalize a step title from a loaded workflow definition. */
|
|
117
|
+
export function stepTitle(
|
|
118
|
+
workflow: LoadedWorkflow | undefined,
|
|
119
|
+
stepId: string,
|
|
120
|
+
): string {
|
|
121
|
+
return inline(workflow?.definition.steps[stepId]?.title ?? stepId);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Combine a display title and identifier without repeating identical text. */
|
|
125
|
+
export function formatStepName(title: string, stepId: string): string {
|
|
126
|
+
const safeStepId = inline(stepId);
|
|
127
|
+
return title === safeStepId ? title : `${title} (${safeStepId})`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Format the currently active execution adapter. */
|
|
131
|
+
export function formatExecution(
|
|
132
|
+
execution: WorkflowStatusExecution | undefined,
|
|
133
|
+
): string | undefined {
|
|
134
|
+
if (!execution) return undefined;
|
|
135
|
+
if (execution.kind === 'main') return 'main agent';
|
|
136
|
+
return `${execution.agent} · ${execution.progress} · ${execution.requestId}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Collapse arbitrary whitespace for safe single-line status rendering. */
|
|
140
|
+
export function inline(value: string): string {
|
|
141
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Derive elapsed run time from an immutable status snapshot. */
|
|
145
|
+
export function elapsedMs(snapshot: WorkflowStatusSnapshot): number {
|
|
146
|
+
const { run } = snapshot;
|
|
147
|
+
const end =
|
|
148
|
+
run.status === 'running' || run.status === 'awaiting-gate'
|
|
149
|
+
? snapshot.now
|
|
150
|
+
: run.updatedAt;
|
|
151
|
+
return Math.max(0, end - run.startedAt);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Format elapsed milliseconds using the two most useful time units. */
|
|
155
|
+
export function formatElapsed(milliseconds: number): string {
|
|
156
|
+
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1_000));
|
|
157
|
+
const days = Math.floor(totalSeconds / 86_400);
|
|
158
|
+
const hours = Math.floor((totalSeconds % 86_400) / 3_600);
|
|
159
|
+
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
|
160
|
+
const seconds = totalSeconds % 60;
|
|
161
|
+
if (days > 0) return `${days}d ${hours}h`;
|
|
162
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
163
|
+
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
|
164
|
+
return `${seconds}s`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Format a local timestamp for the workflow status board. */
|
|
168
|
+
export function formatTimestamp(milliseconds: number): string {
|
|
169
|
+
const value = new Date(milliseconds);
|
|
170
|
+
if (!Number.isFinite(value.getTime())) return 'unknown';
|
|
171
|
+
const year = value.getFullYear();
|
|
172
|
+
const month = String(value.getMonth() + 1).padStart(2, '0');
|
|
173
|
+
const day = String(value.getDate()).padStart(2, '0');
|
|
174
|
+
const hours = String(value.getHours()).padStart(2, '0');
|
|
175
|
+
const minutes = String(value.getMinutes()).padStart(2, '0');
|
|
176
|
+
const seconds = String(value.getSeconds()).padStart(2, '0');
|
|
177
|
+
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
178
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
truncateToWidth,
|
|
3
|
+
visibleWidth,
|
|
4
|
+
wrapTextWithAnsi,
|
|
5
|
+
} from '@earendil-works/pi-tui';
|
|
6
|
+
import type { WorkflowStatusTheme, WorkflowStatusThemeColor } from './types.ts';
|
|
7
|
+
|
|
8
|
+
/** Clamp wrapped rows and mark the final visible row as truncated. */
|
|
9
|
+
export function clampRows(
|
|
10
|
+
lines: ReadonlyArray<string>,
|
|
11
|
+
maximum: number,
|
|
12
|
+
width: number,
|
|
13
|
+
theme: WorkflowStatusTheme,
|
|
14
|
+
): Array<string> {
|
|
15
|
+
if (lines.length <= maximum) return [...lines];
|
|
16
|
+
const visible = lines.slice(0, maximum);
|
|
17
|
+
const last = visible.at(-1) ?? '';
|
|
18
|
+
return visible.map((line, index) =>
|
|
19
|
+
index === maximum - 1
|
|
20
|
+
? truncateToWidth(last, Math.max(1, width - 1), '', true) +
|
|
21
|
+
theme.fg('dim', '…')
|
|
22
|
+
: line,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Render a wrapping label/value pair within a fixed visible width. */
|
|
27
|
+
export function keyValueLines(
|
|
28
|
+
theme: WorkflowStatusTheme,
|
|
29
|
+
label: string,
|
|
30
|
+
rawValue: string,
|
|
31
|
+
width: number,
|
|
32
|
+
valueColor: WorkflowStatusThemeColor = 'text',
|
|
33
|
+
): Array<string> {
|
|
34
|
+
const safeWidth = Math.max(1, width);
|
|
35
|
+
const labelWidth = Math.min(10, Math.max(7, label.length + 1));
|
|
36
|
+
const valueWidth = Math.max(1, safeWidth - labelWidth);
|
|
37
|
+
const value = theme.fg(valueColor, rawValue.replace(/\s+/g, ' ').trim());
|
|
38
|
+
const wrapped = wrapTextWithAnsi(value, valueWidth);
|
|
39
|
+
const prefix = theme.fg('muted', label.padEnd(labelWidth));
|
|
40
|
+
return (wrapped.length > 0 ? wrapped : ['']).map((line, index) =>
|
|
41
|
+
index === 0 ? `${prefix}${line}` : `${' '.repeat(labelWidth)}${line}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Wrap content in a Unicode box constrained to a fixed width. */
|
|
46
|
+
export function boxed(
|
|
47
|
+
theme: WorkflowStatusTheme,
|
|
48
|
+
title: string,
|
|
49
|
+
width: number,
|
|
50
|
+
content: ReadonlyArray<string>,
|
|
51
|
+
color: WorkflowStatusThemeColor = 'borderMuted',
|
|
52
|
+
): Array<string> {
|
|
53
|
+
const safeWidth = Math.max(8, Math.floor(width));
|
|
54
|
+
const bodyWidth = Math.max(1, safeWidth - 4);
|
|
55
|
+
const topLabel = `╭─ ${title} `;
|
|
56
|
+
const top = `${topLabel}${'─'.repeat(
|
|
57
|
+
Math.max(0, safeWidth - visibleWidth(topLabel) - 1),
|
|
58
|
+
)}╮`;
|
|
59
|
+
const bottom = `╰${'─'.repeat(Math.max(0, safeWidth - 2))}╯`;
|
|
60
|
+
const body = content.length > 0 ? content : [''];
|
|
61
|
+
return [
|
|
62
|
+
theme.fg(color, top),
|
|
63
|
+
...body.map(
|
|
64
|
+
(line) =>
|
|
65
|
+
`${theme.fg(color, '│')} ${padAnsi(
|
|
66
|
+
truncateToWidth(line, bodyWidth),
|
|
67
|
+
bodyWidth,
|
|
68
|
+
)} ${theme.fg(color, '│')}`,
|
|
69
|
+
),
|
|
70
|
+
theme.fg(color, bottom),
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Join two independently boxed panels row-by-row. */
|
|
75
|
+
export function joinPanels(
|
|
76
|
+
left: ReadonlyArray<string>,
|
|
77
|
+
leftWidth: number,
|
|
78
|
+
right: ReadonlyArray<string>,
|
|
79
|
+
rightWidth: number,
|
|
80
|
+
gap: number,
|
|
81
|
+
): Array<string> {
|
|
82
|
+
const height = Math.max(left.length, right.length);
|
|
83
|
+
return Array.from({ length: height }, (_, index) => {
|
|
84
|
+
const leftLine = padAnsi(left[index] ?? '', leftWidth);
|
|
85
|
+
const rightLine = padAnsi(right[index] ?? '', rightWidth);
|
|
86
|
+
return `${leftLine}${' '.repeat(gap)}${rightLine}`;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Join left and right text into one fixed-width status row. */
|
|
91
|
+
export function joinColumns(
|
|
92
|
+
left: string,
|
|
93
|
+
right: string,
|
|
94
|
+
width: number,
|
|
95
|
+
leftWidth: number,
|
|
96
|
+
): string {
|
|
97
|
+
const safeLeftWidth = Math.max(1, Math.min(leftWidth, width - 2));
|
|
98
|
+
const rightWidth = Math.max(1, width - safeLeftWidth - 1);
|
|
99
|
+
return `${padAnsi(
|
|
100
|
+
truncateToWidth(left, safeLeftWidth),
|
|
101
|
+
safeLeftWidth,
|
|
102
|
+
)} ${truncateToWidth(right, rightWidth)}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Pad ANSI-styled text to a target visible width. */
|
|
106
|
+
export function padAnsi(value: string, width: number): string {
|
|
107
|
+
const visible = visibleWidth(value);
|
|
108
|
+
return visible >= width ? value : `${value}${' '.repeat(width - visible)}`;
|
|
109
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { boxed, joinPanels } from './layout.ts';
|
|
2
|
+
import { renderPathLines } from './render-path.ts';
|
|
3
|
+
import { renderHeaderLines, renderSummaryLines } from './render-summary.ts';
|
|
4
|
+
import type { WorkflowStatusSnapshot, WorkflowStatusTheme } from './types.ts';
|
|
5
|
+
|
|
6
|
+
const WIDE_LAYOUT_MIN_COLUMNS = 92;
|
|
7
|
+
|
|
8
|
+
/** Render the complete workflow status board without terminal pagination. */
|
|
9
|
+
export function renderBoard(
|
|
10
|
+
theme: WorkflowStatusTheme,
|
|
11
|
+
snapshot: WorkflowStatusSnapshot,
|
|
12
|
+
width: number,
|
|
13
|
+
shouldShowCloseHint: boolean,
|
|
14
|
+
statusShortcutLabel: string,
|
|
15
|
+
): Array<string> {
|
|
16
|
+
const header = boxed(
|
|
17
|
+
theme,
|
|
18
|
+
'✦ Workflow Status',
|
|
19
|
+
width,
|
|
20
|
+
renderHeaderLines(theme, snapshot, width - 4),
|
|
21
|
+
'borderAccent',
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const body =
|
|
25
|
+
width >= WIDE_LAYOUT_MIN_COLUMNS
|
|
26
|
+
? renderWideBody(theme, snapshot, width)
|
|
27
|
+
: renderNarrowBody(theme, snapshot, width);
|
|
28
|
+
const lines = [...header, '', ...body];
|
|
29
|
+
return shouldShowCloseHint
|
|
30
|
+
? [
|
|
31
|
+
...lines,
|
|
32
|
+
'',
|
|
33
|
+
theme.fg('dim', `${statusShortcutLabel} / q / Esc hide · live refresh`),
|
|
34
|
+
]
|
|
35
|
+
: lines;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderWideBody(
|
|
39
|
+
theme: WorkflowStatusTheme,
|
|
40
|
+
snapshot: WorkflowStatusSnapshot,
|
|
41
|
+
width: number,
|
|
42
|
+
): Array<string> {
|
|
43
|
+
const gap = 2;
|
|
44
|
+
const summaryWidth = Math.min(42, Math.max(36, Math.floor(width * 0.36)));
|
|
45
|
+
const pathWidth = width - summaryWidth - gap;
|
|
46
|
+
const summary = boxed(
|
|
47
|
+
theme,
|
|
48
|
+
'Run Summary',
|
|
49
|
+
summaryWidth,
|
|
50
|
+
renderSummaryLines(theme, snapshot, summaryWidth - 4),
|
|
51
|
+
);
|
|
52
|
+
const path = boxed(
|
|
53
|
+
theme,
|
|
54
|
+
'Execution Path',
|
|
55
|
+
pathWidth,
|
|
56
|
+
renderPathLines(theme, snapshot, pathWidth - 4),
|
|
57
|
+
'borderAccent',
|
|
58
|
+
);
|
|
59
|
+
return joinPanels(summary, summaryWidth, path, pathWidth, gap);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function renderNarrowBody(
|
|
63
|
+
theme: WorkflowStatusTheme,
|
|
64
|
+
snapshot: WorkflowStatusSnapshot,
|
|
65
|
+
width: number,
|
|
66
|
+
): Array<string> {
|
|
67
|
+
return [
|
|
68
|
+
...boxed(
|
|
69
|
+
theme,
|
|
70
|
+
'Run Summary',
|
|
71
|
+
width,
|
|
72
|
+
renderSummaryLines(theme, snapshot, width - 4),
|
|
73
|
+
),
|
|
74
|
+
'',
|
|
75
|
+
...boxed(
|
|
76
|
+
theme,
|
|
77
|
+
'Execution Path',
|
|
78
|
+
width,
|
|
79
|
+
renderPathLines(theme, snapshot, width - 4),
|
|
80
|
+
'borderAccent',
|
|
81
|
+
),
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Render the empty state shown before a workflow checkpoint exists. */
|
|
86
|
+
export function renderEmptyBoard(
|
|
87
|
+
theme: WorkflowStatusTheme,
|
|
88
|
+
width: number,
|
|
89
|
+
): Array<string> {
|
|
90
|
+
return [
|
|
91
|
+
...boxed(
|
|
92
|
+
theme,
|
|
93
|
+
'✦ Workflow Status',
|
|
94
|
+
width,
|
|
95
|
+
[theme.fg('muted', 'No workflow checkpoint in this session')],
|
|
96
|
+
'borderAccent',
|
|
97
|
+
),
|
|
98
|
+
'',
|
|
99
|
+
theme.fg('dim', 'q / Esc close'),
|
|
100
|
+
];
|
|
101
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { truncateToWidth } from '@earendil-works/pi-tui';
|
|
2
|
+
import type { LoadedWorkflow } from '../config/types.ts';
|
|
3
|
+
import type { StepHistoryEntry } from '../engine/state.ts';
|
|
4
|
+
import {
|
|
5
|
+
inline,
|
|
6
|
+
runDisplayStatus,
|
|
7
|
+
statusColor,
|
|
8
|
+
statusGlyph,
|
|
9
|
+
statusLabel,
|
|
10
|
+
stepTitle,
|
|
11
|
+
} from './formatting.ts';
|
|
12
|
+
import { joinColumns, padAnsi } from './layout.ts';
|
|
13
|
+
import type {
|
|
14
|
+
PathEntry,
|
|
15
|
+
WorkflowStatusSnapshot,
|
|
16
|
+
WorkflowStatusTheme,
|
|
17
|
+
} from './types.ts';
|
|
18
|
+
|
|
19
|
+
const MAX_PATH_ROWS = 16;
|
|
20
|
+
|
|
21
|
+
function historyPathEntry(
|
|
22
|
+
workflow: LoadedWorkflow | undefined,
|
|
23
|
+
entry: StepHistoryEntry,
|
|
24
|
+
visit: number,
|
|
25
|
+
): PathEntry {
|
|
26
|
+
return {
|
|
27
|
+
stepId: entry.stepId,
|
|
28
|
+
title: stepTitle(workflow, entry.stepId),
|
|
29
|
+
status: 'completed',
|
|
30
|
+
visit,
|
|
31
|
+
outcome: entry.outcome,
|
|
32
|
+
isCurrent: false,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Build immutable display entries from completed history and current state. */
|
|
37
|
+
export function buildPathEntries(
|
|
38
|
+
snapshot: WorkflowStatusSnapshot,
|
|
39
|
+
): Array<PathEntry> {
|
|
40
|
+
const { run, workflow } = snapshot;
|
|
41
|
+
const visits = new Map<string, number>();
|
|
42
|
+
const completedEntries = run.history.map((entry) => {
|
|
43
|
+
const visit = (visits.get(entry.stepId) ?? 0) + 1;
|
|
44
|
+
visits.set(entry.stepId, visit);
|
|
45
|
+
return historyPathEntry(workflow, entry, visit);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (run.status !== 'completed') {
|
|
49
|
+
return [
|
|
50
|
+
...completedEntries,
|
|
51
|
+
{
|
|
52
|
+
stepId: run.currentStepId,
|
|
53
|
+
title: stepTitle(workflow, run.currentStepId),
|
|
54
|
+
status: runDisplayStatus(run),
|
|
55
|
+
visit: Math.max(
|
|
56
|
+
visits.get(run.currentStepId) ?? 0,
|
|
57
|
+
run.visits[run.currentStepId] ?? 1,
|
|
58
|
+
),
|
|
59
|
+
isCurrent: true,
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
if (
|
|
64
|
+
completedEntries.length === 0 ||
|
|
65
|
+
completedEntries.at(-1)?.stepId !== run.currentStepId
|
|
66
|
+
) {
|
|
67
|
+
return [
|
|
68
|
+
...completedEntries,
|
|
69
|
+
{
|
|
70
|
+
stepId: run.currentStepId,
|
|
71
|
+
title: stepTitle(workflow, run.currentStepId),
|
|
72
|
+
status: 'completed',
|
|
73
|
+
visit: Math.max(1, run.visits[run.currentStepId] ?? 1),
|
|
74
|
+
isCurrent: true,
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
return completedEntries;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Render the execution-path panel for a workflow snapshot. */
|
|
82
|
+
export function renderPathLines(
|
|
83
|
+
theme: WorkflowStatusTheme,
|
|
84
|
+
snapshot: WorkflowStatusSnapshot,
|
|
85
|
+
width: number,
|
|
86
|
+
): Array<string> {
|
|
87
|
+
const entries = buildPathEntries(snapshot);
|
|
88
|
+
if (entries.length === 0) {
|
|
89
|
+
return [theme.fg('muted', 'No step attempts recorded')];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const hidden = Math.max(0, entries.length - MAX_PATH_ROWS);
|
|
93
|
+
const prefix =
|
|
94
|
+
hidden > 0
|
|
95
|
+
? [
|
|
96
|
+
theme.fg(
|
|
97
|
+
'dim',
|
|
98
|
+
`… ${hidden} earlier attempt${hidden === 1 ? '' : 's'}`,
|
|
99
|
+
),
|
|
100
|
+
]
|
|
101
|
+
: [];
|
|
102
|
+
const visibleRows = entries.slice(hidden).map((entry) => {
|
|
103
|
+
const visit =
|
|
104
|
+
entry.visit > 1 ? theme.fg('dim', ` · visit ${entry.visit}`) : '';
|
|
105
|
+
const left = `${statusGlyph(theme, entry.status, snapshot.now)} ${theme.fg(
|
|
106
|
+
entry.isCurrent ? 'text' : 'muted',
|
|
107
|
+
entry.title,
|
|
108
|
+
)}${visit}`;
|
|
109
|
+
const right = entry.outcome
|
|
110
|
+
? `${statusLabel(entry.status)} · ${inline(entry.outcome)}`
|
|
111
|
+
: statusLabel(entry.status);
|
|
112
|
+
const row = joinColumns(
|
|
113
|
+
left,
|
|
114
|
+
theme.fg(statusColor(entry.status), right),
|
|
115
|
+
width,
|
|
116
|
+
Math.max(12, Math.floor(width * 0.58)),
|
|
117
|
+
);
|
|
118
|
+
return entry.isCurrent
|
|
119
|
+
? theme.bg('selectedBg', padAnsi(row, width))
|
|
120
|
+
: truncateToWidth(row, width);
|
|
121
|
+
});
|
|
122
|
+
return [...prefix, ...visibleRows];
|
|
123
|
+
}
|