@agimon-ai/doompi-workflow 0.0.1-alpha.37 → 0.0.1-alpha.39
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/LICENSE +6 -0
- package/README.md +14 -8
- package/dist/_virtual/_rolldown/runtime.cjs +1 -0
- package/dist/adapters/pi/extension.cjs +1 -1
- package/dist/adapters/pi/extension.mjs +1 -1
- package/dist/adapters/pi/workflow/piExtension.cjs +6 -8
- package/dist/adapters/pi/workflow/piExtension.cjs.map +1 -1
- package/dist/adapters/pi/workflow/piExtension.mjs +6 -8
- package/dist/adapters/pi/workflow/piExtension.mjs.map +1 -1
- package/dist/adapters/pi/workflow/piTools.cjs +3 -3
- package/dist/adapters/pi/workflow/piTools.mjs +3 -3
- package/dist/adapters/pi/workflow/workflowLauncher.cjs +1 -1
- package/dist/adapters/pi/workflow/workflowLauncher.cjs.map +1 -1
- package/dist/adapters/pi/workflow/workflowLauncher.mjs +1 -1
- package/dist/adapters/pi/workflow/workflowLauncher.mjs.map +1 -1
- package/dist/adapters/workflowCatalogChannel.cjs +2 -0
- package/dist/adapters/workflowCatalogChannel.cjs.map +1 -0
- package/dist/adapters/workflowCatalogChannel.d.cts +23 -0
- package/dist/adapters/workflowCatalogChannel.d.cts.map +1 -0
- package/dist/adapters/workflowCatalogChannel.d.mts +23 -0
- package/dist/adapters/workflowCatalogChannel.d.mts.map +1 -0
- package/dist/adapters/workflowCatalogChannel.mjs +2 -0
- package/dist/adapters/workflowCatalogChannel.mjs.map +1 -0
- package/dist/adapters/workflowHubApi.cjs +2 -0
- package/dist/adapters/workflowHubApi.cjs.map +1 -0
- package/dist/adapters/workflowHubApi.d.cts +24 -0
- package/dist/adapters/workflowHubApi.d.cts.map +1 -0
- package/dist/adapters/workflowHubApi.d.mts +24 -0
- package/dist/adapters/workflowHubApi.d.mts.map +1 -0
- package/dist/adapters/workflowHubApi.mjs +2 -0
- package/dist/adapters/workflowHubApi.mjs.map +1 -0
- package/dist/adapters/workflowWatcher.cjs +2 -0
- package/dist/adapters/workflowWatcher.cjs.map +1 -0
- package/dist/adapters/workflowWatcher.d.cts +24 -0
- package/dist/adapters/workflowWatcher.d.cts.map +1 -0
- package/dist/adapters/workflowWatcher.d.mts +24 -0
- package/dist/adapters/workflowWatcher.d.mts.map +1 -0
- package/dist/adapters/workflowWatcher.mjs +2 -0
- package/dist/adapters/workflowWatcher.mjs.map +1 -0
- package/dist/adapters/workflowsHubChannel.cjs +2 -0
- package/dist/adapters/workflowsHubChannel.cjs.map +1 -0
- package/dist/adapters/workflowsHubChannel.d.cts +20 -0
- package/dist/adapters/workflowsHubChannel.d.cts.map +1 -0
- package/dist/adapters/workflowsHubChannel.d.mts +20 -0
- package/dist/adapters/workflowsHubChannel.d.mts.map +1 -0
- package/dist/adapters/workflowsHubChannel.mjs +2 -0
- package/dist/adapters/workflowsHubChannel.mjs.map +1 -0
- package/dist/hubApi.cjs +1 -0
- package/dist/hubApi.d.cts +2 -0
- package/dist/hubApi.d.mts +2 -0
- package/dist/hubApi.mjs +1 -0
- package/dist/schemas/workflowPi.cjs +1 -1
- package/dist/schemas/workflowPi.cjs.map +1 -1
- package/dist/schemas/workflowPi.mjs +1 -1
- package/dist/schemas/workflowPi.mjs.map +1 -1
- package/dist/services/webWorkflowCatalog.cjs +2 -0
- package/dist/services/webWorkflowCatalog.cjs.map +1 -0
- package/dist/services/webWorkflowCatalog.d.cts +49 -0
- package/dist/services/webWorkflowCatalog.d.cts.map +1 -0
- package/dist/services/webWorkflowCatalog.d.mts +49 -0
- package/dist/services/webWorkflowCatalog.d.mts.map +1 -0
- package/dist/services/webWorkflowCatalog.mjs +2 -0
- package/dist/services/webWorkflowCatalog.mjs.map +1 -0
- package/dist/services/workflowLaunchCommand.cjs +2 -0
- package/dist/services/workflowLaunchCommand.cjs.map +1 -0
- package/dist/services/workflowLaunchCommand.mjs +2 -0
- package/dist/services/workflowLaunchCommand.mjs.map +1 -0
- package/dist/services/workflowRuns.cjs +3 -0
- package/dist/services/workflowRuns.cjs.map +1 -0
- package/dist/services/workflowRuns.d.cts +17 -0
- package/dist/services/workflowRuns.d.cts.map +1 -0
- package/dist/services/workflowRuns.d.mts +17 -0
- package/dist/services/workflowRuns.d.mts.map +1 -0
- package/dist/services/workflowRuns.mjs +3 -0
- package/dist/services/workflowRuns.mjs.map +1 -0
- package/dist/services/workflowTerminal.cjs +2 -0
- package/dist/services/workflowTerminal.cjs.map +1 -0
- package/dist/services/workflowTerminal.mjs +2 -0
- package/dist/services/workflowTerminal.mjs.map +1 -0
- package/dist/tui/workflow/workflowCatalog.cjs +1 -1
- package/dist/tui/workflow/workflowCatalog.cjs.map +1 -1
- package/dist/tui/workflow/workflowCatalog.mjs +1 -1
- package/dist/tui/workflow/workflowCatalog.mjs.map +1 -1
- package/dist/types/webWorkflowTerminal.cjs +2 -0
- package/dist/types/webWorkflowTerminal.cjs.map +1 -0
- package/dist/types/webWorkflowTerminal.mjs +2 -0
- package/dist/types/webWorkflowTerminal.mjs.map +1 -0
- package/dist/types/webWorkflows.cjs +2 -0
- package/dist/types/webWorkflows.cjs.map +1 -0
- package/dist/types/webWorkflows.d.cts +95 -0
- package/dist/types/webWorkflows.d.cts.map +1 -0
- package/dist/types/webWorkflows.d.mts +95 -0
- package/dist/types/webWorkflows.d.mts.map +1 -0
- package/dist/types/webWorkflows.mjs +2 -0
- package/dist/types/webWorkflows.mjs.map +1 -0
- package/dist/webHub.cjs +1 -0
- package/dist/webHub.d.cts +3 -0
- package/dist/webHub.d.mts +3 -0
- package/dist/webHub.mjs +1 -0
- package/package.json +47 -11
- package/src/prompts/doompi-author-workflow/SKILL.md +1 -3
- package/src/types/webWorkflowTerminal.ts +92 -0
- package/src/types/webWorkflows.ts +145 -0
- package/web/ArtifactsPane.tsx +433 -0
- package/web/LICENSE +29 -0
- package/web/LaunchWorkflowDialog.tsx +249 -0
- package/web/StepTerminalPanel.tsx +251 -0
- package/web/WorkflowCatalogDrawer.tsx +228 -0
- package/web/WorkflowToolMessage.tsx +54 -0
- package/web/WorkflowsActivitySection.tsx +152 -0
- package/web/WorkflowsPanel.tsx +737 -0
- package/web/ansiSpans.ts +230 -0
- package/web/catalogStore.ts +112 -0
- package/web/index.ts +61 -0
- package/web/launchLine.ts +66 -0
- package/web/runDuration.ts +15 -0
- package/web/terminalApi.ts +151 -0
- package/web/tsconfig.json +22 -0
- package/web/workflowActivity.ts +102 -0
- package/web/workflowToolRender.ts +585 -0
- package/web/workflowsStore.ts +90 -0
- package/dist/tui/workflow/terminalGrid.cjs +0 -2
- package/dist/tui/workflow/terminalGrid.cjs.map +0 -1
- package/dist/tui/workflow/terminalGrid.mjs +0 -2
- package/dist/tui/workflow/terminalGrid.mjs.map +0 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire contract between this package's hub API and its cockpit client:
|
|
3
|
+
* one run's terminal, and the files its run directory holds.
|
|
4
|
+
*
|
|
5
|
+
* Both halves of the cockpit import these names, so the route strings and the
|
|
6
|
+
* event names live here rather than being spelled twice.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Segment this package's API is mounted under, below /api/plugin/. */
|
|
10
|
+
export const WORKFLOW_API_BASE_PATH = 'workflow';
|
|
11
|
+
|
|
12
|
+
/** The SSE event name the screen stream writes. */
|
|
13
|
+
export const WORKFLOW_SCREEN_EVENT = 'screen';
|
|
14
|
+
|
|
15
|
+
/** Where a run's routes live, below the mount every hub-scoped package API shares. */
|
|
16
|
+
export function workflowRunPath(workspace: string, runKey: string): string {
|
|
17
|
+
return `/api/plugin/${WORKFLOW_API_BASE_PATH}/runs/${encodeURIComponent(workspace)}/${encodeURIComponent(runKey)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** What a surface may do with one run's terminal, and why anything is missing. */
|
|
21
|
+
export interface WorkflowTerminalCapabilitiesView {
|
|
22
|
+
readable: boolean;
|
|
23
|
+
writable: boolean;
|
|
24
|
+
resizable: boolean;
|
|
25
|
+
reason?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One frame of a run's terminal, as the stream pushes it. */
|
|
29
|
+
export interface WorkflowScreenEvent {
|
|
30
|
+
/** The visible screen, newest line last, carrying the colour the run printed. */
|
|
31
|
+
lines: string[];
|
|
32
|
+
capabilities: WorkflowTerminalCapabilitiesView;
|
|
33
|
+
/** True once the run has settled, after which the stream ends. */
|
|
34
|
+
ended?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Answer to taking or releasing the keyboard for one run. */
|
|
38
|
+
export interface WorkflowControlResponse {
|
|
39
|
+
/** True when this caller now holds the keyboard. */
|
|
40
|
+
held: boolean;
|
|
41
|
+
/** The token later writes must carry; absent when control was refused or released. */
|
|
42
|
+
token?: string;
|
|
43
|
+
/** Why control was refused, worded for a reader. */
|
|
44
|
+
reason?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Answer after a settled run and its directory have been permanently removed. */
|
|
48
|
+
export interface WorkflowDeleteResponse {
|
|
49
|
+
deleted: true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** How a declared run-directory entry compares with what is on disk. */
|
|
53
|
+
export type WorkflowArtifactState = 'written' | 'empty' | 'pending' | 'unreadable';
|
|
54
|
+
|
|
55
|
+
/** One file or directory in a run's own folder. */
|
|
56
|
+
export interface WorkflowArtifactView {
|
|
57
|
+
/** Path relative to the run directory, which is also the read route's parameter. */
|
|
58
|
+
path: string;
|
|
59
|
+
kind: 'file' | 'directory';
|
|
60
|
+
/** What the workflow says this file is for; empty for a file it never declared. */
|
|
61
|
+
description: string;
|
|
62
|
+
/** The jobs the workflow says write it. */
|
|
63
|
+
producedBy: string[];
|
|
64
|
+
/** True when the workflow's run-directory block names this entry. */
|
|
65
|
+
declared: boolean;
|
|
66
|
+
state: WorkflowArtifactState;
|
|
67
|
+
/** Bytes on disk, absent until the file exists. */
|
|
68
|
+
size?: number;
|
|
69
|
+
/** ISO 8601 of the last write, absent until the file exists. */
|
|
70
|
+
modifiedAt?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface WorkflowArtifactsResponse {
|
|
74
|
+
/** Absolute path of the run's own directory, which the reader may want to open. */
|
|
75
|
+
runDir: string;
|
|
76
|
+
/** What the workflow says the directory is for. */
|
|
77
|
+
description: string;
|
|
78
|
+
artifacts: WorkflowArtifactView[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One artifact's metadata and optional text, as the viewer tab reads it. */
|
|
82
|
+
export interface WorkflowArtifactContentResponse {
|
|
83
|
+
path: string;
|
|
84
|
+
size: number;
|
|
85
|
+
modifiedAt: string;
|
|
86
|
+
/** Media type inferred from the file extension by the trusted hub. Absent on older hubs. */
|
|
87
|
+
mimeType?: string;
|
|
88
|
+
/** Present for text formats; binary files are streamed from the raw route instead. */
|
|
89
|
+
text?: string;
|
|
90
|
+
/** True when textual content was longer than the reader's byte budget. */
|
|
91
|
+
truncated: boolean;
|
|
92
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow run view types shared by this package's hub channel and its web
|
|
3
|
+
* plugin. The shapes mirror what the embedded workflow-mcp engine records;
|
|
4
|
+
* the cockpit renders them as 'workflow_runs' channel payloads.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** workflow-mcp's own stage vocabulary; the registry bucket a run record sits in. */
|
|
8
|
+
export type WorkflowStage = 'running' | 'completed' | 'error';
|
|
9
|
+
|
|
10
|
+
/** workflow-mcp's terminal outcome for a finished run. */
|
|
11
|
+
export type WorkflowOutcome = 'success' | 'skipped' | 'failed' | 'interrupted';
|
|
12
|
+
|
|
13
|
+
/** Job and step states as workflow-mcp records them in the progress log. */
|
|
14
|
+
export type WorkflowProgressState =
|
|
15
|
+
| 'running'
|
|
16
|
+
| 'completed'
|
|
17
|
+
| 'skipped'
|
|
18
|
+
| 'failed'
|
|
19
|
+
| 'pause_requested'
|
|
20
|
+
| 'paused'
|
|
21
|
+
| 'resumed';
|
|
22
|
+
|
|
23
|
+
export interface WorkflowStepView {
|
|
24
|
+
name: string;
|
|
25
|
+
status: WorkflowProgressState;
|
|
26
|
+
/** Why the step is in its state, when the engine recorded one (e.g. a skip condition). */
|
|
27
|
+
reason?: string;
|
|
28
|
+
/** ISO 8601 of the step's first running event. */
|
|
29
|
+
startedAt?: string;
|
|
30
|
+
/** ISO 8601 of the step's terminal event. */
|
|
31
|
+
endedAt?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 'pre' and 'post' are the engine's pseudo-jobs for its pre:/post: blocks. */
|
|
35
|
+
export type WorkflowJobPhase = 'pre' | 'job' | 'post';
|
|
36
|
+
|
|
37
|
+
export interface WorkflowJobView {
|
|
38
|
+
name: string;
|
|
39
|
+
phase: WorkflowJobPhase;
|
|
40
|
+
status: WorkflowProgressState;
|
|
41
|
+
reason?: string;
|
|
42
|
+
/** Position among the run's jobs, when the engine recorded one. */
|
|
43
|
+
index?: number;
|
|
44
|
+
total?: number;
|
|
45
|
+
startedAt?: string;
|
|
46
|
+
endedAt?: string;
|
|
47
|
+
steps: WorkflowStepView[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The job and step a run is on right now; drives the NOW breadcrumb. */
|
|
51
|
+
export interface WorkflowPosition {
|
|
52
|
+
job: string;
|
|
53
|
+
step?: string;
|
|
54
|
+
index?: number;
|
|
55
|
+
total?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One workflow run of a session, read from workflow-mcp's registry: the
|
|
60
|
+
* run.json record plus the folded progress.ndjson job tree.
|
|
61
|
+
*/
|
|
62
|
+
export interface WorkflowRunView {
|
|
63
|
+
/** Registry identity within its workspace; unique per workspace+stage. */
|
|
64
|
+
runKey: string;
|
|
65
|
+
workspace: string;
|
|
66
|
+
displayName: string;
|
|
67
|
+
workflowName?: string;
|
|
68
|
+
workflowPath: string;
|
|
69
|
+
stage: WorkflowStage;
|
|
70
|
+
outcome?: WorkflowOutcome;
|
|
71
|
+
/** Present while the engine is pausing or paused; absent for plain running. */
|
|
72
|
+
executionState?: 'running' | 'pause_requested' | 'paused' | 'resume_requested';
|
|
73
|
+
/** The prompt the run was launched with, when one was recorded. */
|
|
74
|
+
prompt?: string;
|
|
75
|
+
/** ISO 8601, from the run record. */
|
|
76
|
+
startedAt: string;
|
|
77
|
+
finishedAt?: string;
|
|
78
|
+
/** Failure cause with ANSI escapes stripped, present when the run errored. */
|
|
79
|
+
errorMessage?: string;
|
|
80
|
+
/** The job the engine blamed for the failure, when it recorded one. */
|
|
81
|
+
failedJob?: string;
|
|
82
|
+
/** True when reconciliation decided the recorded pid is no longer alive. */
|
|
83
|
+
stale?: boolean;
|
|
84
|
+
staleReason?: string;
|
|
85
|
+
worktreeBranch?: string;
|
|
86
|
+
position?: WorkflowPosition;
|
|
87
|
+
jobs: WorkflowJobView[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const WORKFLOW_RUNS_TYPE = 'workflow_runs';
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One workflow the session's directory can launch, as the catalog drawer
|
|
94
|
+
* lists it: the engine's own catalog entry plus the parsed detail the drawer
|
|
95
|
+
* unfolds and the launch dialog turns into fields.
|
|
96
|
+
*/
|
|
97
|
+
export interface WorkflowCatalogEntryView {
|
|
98
|
+
/** Absolute path, the row's stable identity. */
|
|
99
|
+
path: string;
|
|
100
|
+
relativePath: string;
|
|
101
|
+
name: string;
|
|
102
|
+
description: string;
|
|
103
|
+
tags: string[];
|
|
104
|
+
/** The trigger names the file declares, such as workflow_dispatch. */
|
|
105
|
+
triggers: string[];
|
|
106
|
+
inputs: WorkflowCatalogInputView[];
|
|
107
|
+
jobs: WorkflowCatalogJobView[];
|
|
108
|
+
artifacts: WorkflowCatalogArtifactView[];
|
|
109
|
+
/** Absent when the workflow names no runner map, which means any runner will do. */
|
|
110
|
+
runners?: string[];
|
|
111
|
+
/** Set when the file could not be parsed; the row shows this instead of guessing. */
|
|
112
|
+
error?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface WorkflowCatalogInputView {
|
|
116
|
+
name: string;
|
|
117
|
+
description?: string;
|
|
118
|
+
required?: boolean;
|
|
119
|
+
default?: string;
|
|
120
|
+
type?: string;
|
|
121
|
+
options?: string[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface WorkflowCatalogJobView {
|
|
125
|
+
name: string;
|
|
126
|
+
steps: string[];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface WorkflowCatalogArtifactView {
|
|
130
|
+
path: string;
|
|
131
|
+
kind: 'file' | 'directory';
|
|
132
|
+
description: string;
|
|
133
|
+
producedBy: string[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** What the catalog channel publishes for one session. */
|
|
137
|
+
export interface WorkflowCatalogPayload {
|
|
138
|
+
/** The directory the catalog was read from, which is the session's own. */
|
|
139
|
+
cwd: string;
|
|
140
|
+
workflows: WorkflowCatalogEntryView[];
|
|
141
|
+
/** Present when the directory could not be read at all. */
|
|
142
|
+
warning?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const WORKFLOW_CATALOG_TYPE = 'workflow_catalog';
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { Badge, Button, EmptyState, Markdown } from '@agimon-ai/doompi-web-components';
|
|
2
|
+
import type { TransientTab, WebPluginSlotProps } from '@agimon-ai/doompi-web-contracts';
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import type { WorkflowRunView } from '../src/types/webWorkflows.ts';
|
|
5
|
+
import type {
|
|
6
|
+
WorkflowArtifactContentResponse,
|
|
7
|
+
WorkflowArtifactsResponse,
|
|
8
|
+
WorkflowArtifactView,
|
|
9
|
+
} from '../src/types/webWorkflowTerminal.ts';
|
|
10
|
+
import { artifactContentUrl, fetchArtifact, fetchArtifacts } from './terminalApi.ts';
|
|
11
|
+
|
|
12
|
+
const TAB_ID_PREFIX = 'workflows-artifact-';
|
|
13
|
+
const BYTES_PER_UNIT = 1024;
|
|
14
|
+
const UNITS = ['B', 'KB', 'MB'] as const;
|
|
15
|
+
|
|
16
|
+
function formatSize(bytes: number | undefined): string {
|
|
17
|
+
if (bytes === undefined) return '';
|
|
18
|
+
let value = bytes;
|
|
19
|
+
let unit = 0;
|
|
20
|
+
while (value >= BYTES_PER_UNIT && unit < UNITS.length - 1) {
|
|
21
|
+
value /= BYTES_PER_UNIT;
|
|
22
|
+
unit += 1;
|
|
23
|
+
}
|
|
24
|
+
return `${unit === 0 ? value : value.toFixed(1)} ${UNITS[unit]}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function formatWhen(iso: string | undefined): string {
|
|
28
|
+
if (iso === undefined) return '';
|
|
29
|
+
const parsed = Date.parse(iso);
|
|
30
|
+
if (!Number.isFinite(parsed)) return '';
|
|
31
|
+
return new Date(parsed).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const STATE_GLYPH: Readonly<Record<WorkflowArtifactView['state'], { glyph: string; className: string }>> = {
|
|
35
|
+
written: { glyph: '✓', className: 'text-doom-green' },
|
|
36
|
+
empty: { glyph: '○', className: 'text-doom-yellow' },
|
|
37
|
+
pending: { glyph: '○', className: 'text-doom-faint' },
|
|
38
|
+
unreadable: { glyph: '!', className: 'text-doom-red' },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function parseDelimited(text: string, delimiter: ',' | '\t'): string[][] {
|
|
42
|
+
const rows: string[][] = [];
|
|
43
|
+
let row: string[] = [];
|
|
44
|
+
let cell = '';
|
|
45
|
+
let quoted = false;
|
|
46
|
+
for (let index = 0; index <= text.length && rows.length < 500; index += 1) {
|
|
47
|
+
const character = text[index] ?? '\n';
|
|
48
|
+
if (quoted) {
|
|
49
|
+
if (character === '"' && text[index + 1] === '"') {
|
|
50
|
+
cell += '"';
|
|
51
|
+
index += 1;
|
|
52
|
+
} else if (character === '"') {
|
|
53
|
+
quoted = false;
|
|
54
|
+
} else {
|
|
55
|
+
cell += character;
|
|
56
|
+
}
|
|
57
|
+
} else if (character === '"' && cell === '') {
|
|
58
|
+
quoted = true;
|
|
59
|
+
} else if (character === delimiter) {
|
|
60
|
+
row.push(cell);
|
|
61
|
+
cell = '';
|
|
62
|
+
} else if (character === '\n') {
|
|
63
|
+
row.push(cell.replace(/\r$/, ''));
|
|
64
|
+
rows.push(row);
|
|
65
|
+
row = [];
|
|
66
|
+
cell = '';
|
|
67
|
+
} else {
|
|
68
|
+
cell += character;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return rows;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function DelimitedPreview({ text, delimiter }: { text: string; delimiter: ',' | '\t' }) {
|
|
75
|
+
const rows = parseDelimited(text, delimiter);
|
|
76
|
+
const [head = [], ...body] = rows;
|
|
77
|
+
return (
|
|
78
|
+
<div className="overflow-auto">
|
|
79
|
+
<table data-testid="artifact-table" className="min-w-full border-collapse font-mono text-[11px]">
|
|
80
|
+
<thead className="sticky top-0 bg-doom-panel text-doom-hi">
|
|
81
|
+
<tr>
|
|
82
|
+
{head.map((cell, index) => (
|
|
83
|
+
<th key={index} className="border border-doom-border px-2 py-1.5 text-left font-bold">
|
|
84
|
+
{cell}
|
|
85
|
+
</th>
|
|
86
|
+
))}
|
|
87
|
+
</tr>
|
|
88
|
+
</thead>
|
|
89
|
+
<tbody>
|
|
90
|
+
{body.map((cells, rowIndex) => (
|
|
91
|
+
<tr key={rowIndex}>
|
|
92
|
+
{cells.map((cell, cellIndex) => (
|
|
93
|
+
<td key={cellIndex} className="border border-doom-border px-2 py-1 align-top text-doom-text">
|
|
94
|
+
{cell}
|
|
95
|
+
</td>
|
|
96
|
+
))}
|
|
97
|
+
</tr>
|
|
98
|
+
))}
|
|
99
|
+
</tbody>
|
|
100
|
+
</table>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Keeps a newly built viewer compatible while an older hub process is restarting. */
|
|
106
|
+
function previewMimeType(content: WorkflowArtifactContentResponse): string {
|
|
107
|
+
if (content.mimeType !== undefined) return content.mimeType;
|
|
108
|
+
const extension = content.path.split('.').pop()?.toLowerCase();
|
|
109
|
+
if (extension === 'md' || extension === 'markdown') return 'text/markdown';
|
|
110
|
+
if (extension === 'json') return 'application/json';
|
|
111
|
+
if (extension === 'csv') return 'text/csv';
|
|
112
|
+
if (extension === 'tsv') return 'text/tab-separated-values';
|
|
113
|
+
if (extension === 'html' || extension === 'htm') return 'text/html';
|
|
114
|
+
if (extension === 'pdf') return 'application/pdf';
|
|
115
|
+
if (['bmp', 'gif', 'ico', 'jpeg', 'jpg', 'png', 'svg', 'webp'].includes(extension ?? '')) return 'image/unknown';
|
|
116
|
+
if (['aac', 'm4a', 'mp3', 'oga', 'ogg', 'wav'].includes(extension ?? '')) return 'audio/unknown';
|
|
117
|
+
if (['mov', 'mp4', 'ogv', 'webm'].includes(extension ?? '')) return 'video/unknown';
|
|
118
|
+
return content.text === undefined ? 'application/octet-stream' : 'text/plain';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function ArtifactPreview({
|
|
122
|
+
content,
|
|
123
|
+
rawUrl,
|
|
124
|
+
raw,
|
|
125
|
+
}: {
|
|
126
|
+
content: WorkflowArtifactContentResponse;
|
|
127
|
+
rawUrl: string;
|
|
128
|
+
raw: boolean;
|
|
129
|
+
}) {
|
|
130
|
+
const text = content.text ?? '';
|
|
131
|
+
const mimeType = previewMimeType(content);
|
|
132
|
+
if (content.size === 0) {
|
|
133
|
+
return (
|
|
134
|
+
<EmptyState
|
|
135
|
+
data-testid="artifact-empty"
|
|
136
|
+
title="this artifact is empty"
|
|
137
|
+
description="The workflow created the file but did not write any content."
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (mimeType === 'text/markdown') {
|
|
142
|
+
return raw ? (
|
|
143
|
+
<pre
|
|
144
|
+
data-testid="artifact-raw"
|
|
145
|
+
className="whitespace-pre-wrap break-words font-mono text-[11px] leading-[17px] text-doom-text"
|
|
146
|
+
>
|
|
147
|
+
{text}
|
|
148
|
+
</pre>
|
|
149
|
+
) : (
|
|
150
|
+
<div data-testid="artifact-markdown" className="max-w-5xl text-[13px] text-doom-text">
|
|
151
|
+
<Markdown text={text} />
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (mimeType === 'application/json') {
|
|
156
|
+
let formatted = text;
|
|
157
|
+
try {
|
|
158
|
+
formatted = JSON.stringify(JSON.parse(text) as unknown, null, 2);
|
|
159
|
+
} catch {
|
|
160
|
+
// An incomplete file remains useful as literal text while a workflow writes it.
|
|
161
|
+
}
|
|
162
|
+
return (
|
|
163
|
+
<pre className="whitespace-pre-wrap break-words font-mono text-[11px] leading-[17px] text-doom-text">
|
|
164
|
+
{formatted}
|
|
165
|
+
</pre>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (mimeType === 'text/csv' || mimeType === 'text/tab-separated-values') {
|
|
169
|
+
return <DelimitedPreview text={text} delimiter={mimeType === 'text/csv' ? ',' : '\t'} />;
|
|
170
|
+
}
|
|
171
|
+
if (mimeType === 'text/html') {
|
|
172
|
+
return (
|
|
173
|
+
<iframe
|
|
174
|
+
data-testid="artifact-html"
|
|
175
|
+
title={content.path}
|
|
176
|
+
sandbox=""
|
|
177
|
+
referrerPolicy="no-referrer"
|
|
178
|
+
srcDoc={text}
|
|
179
|
+
className="min-h-[520px] w-full rounded bg-white"
|
|
180
|
+
/>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (mimeType.startsWith('image/')) {
|
|
184
|
+
return (
|
|
185
|
+
<img
|
|
186
|
+
data-testid="artifact-image"
|
|
187
|
+
src={rawUrl}
|
|
188
|
+
alt={content.path}
|
|
189
|
+
className="max-h-full max-w-full object-contain"
|
|
190
|
+
/>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (mimeType === 'application/pdf') {
|
|
194
|
+
return <object data-testid="artifact-pdf" data={rawUrl} type="application/pdf" className="min-h-[600px] w-full" />;
|
|
195
|
+
}
|
|
196
|
+
if (mimeType.startsWith('audio/')) {
|
|
197
|
+
return <audio data-testid="artifact-audio" src={rawUrl} controls preload="metadata" className="w-full" />;
|
|
198
|
+
}
|
|
199
|
+
if (mimeType.startsWith('video/')) {
|
|
200
|
+
return (
|
|
201
|
+
<video data-testid="artifact-video" src={rawUrl} controls preload="metadata" className="max-h-full max-w-full" />
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (content.text !== undefined) {
|
|
205
|
+
return (
|
|
206
|
+
<pre className="whitespace-pre-wrap break-words font-mono text-[11px] leading-[17px] text-doom-text">{text}</pre>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return (
|
|
210
|
+
<EmptyState title="preview unavailable" description="Download this artifact to open it with a local application." />
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** One artifact, read-only, in its own tab. */
|
|
215
|
+
export function artifactTab(run: WorkflowRunView, path: string): TransientTab {
|
|
216
|
+
return {
|
|
217
|
+
id: `${TAB_ID_PREFIX}${run.workspace}-${run.runKey}-${path}`.replace(/[^\w-]+/g, '-'),
|
|
218
|
+
label: path.split('/').pop() ?? path,
|
|
219
|
+
panel: (props: WebPluginSlotProps) => (
|
|
220
|
+
<ArtifactViewerPanel {...props} workspace={run.workspace} runKey={run.runKey} path={path} />
|
|
221
|
+
),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* One artifact's text.
|
|
227
|
+
*
|
|
228
|
+
* Read-only and reloadable: a run keeps writing while somebody is reading, and
|
|
229
|
+
* the file on the next read is the one that matters.
|
|
230
|
+
*/
|
|
231
|
+
export function ArtifactViewerPanel({
|
|
232
|
+
workspace,
|
|
233
|
+
runKey,
|
|
234
|
+
path,
|
|
235
|
+
}: WebPluginSlotProps & { workspace: string; runKey: string; path: string }) {
|
|
236
|
+
const [content, setContent] = useState<WorkflowArtifactContentResponse>();
|
|
237
|
+
const [error, setError] = useState<string>();
|
|
238
|
+
const [reloads, setReloads] = useState(0);
|
|
239
|
+
const [raw, setRaw] = useState(false);
|
|
240
|
+
|
|
241
|
+
useEffect(() => {
|
|
242
|
+
let live = true;
|
|
243
|
+
void fetchArtifact(workspace, runKey, path).then((result) => {
|
|
244
|
+
if (!live) return;
|
|
245
|
+
if ('error' in result) {
|
|
246
|
+
setError(result.error);
|
|
247
|
+
setContent(undefined);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
setError(undefined);
|
|
251
|
+
setContent(result.artifact);
|
|
252
|
+
});
|
|
253
|
+
return () => {
|
|
254
|
+
live = false;
|
|
255
|
+
};
|
|
256
|
+
}, [workspace, runKey, path, reloads]);
|
|
257
|
+
|
|
258
|
+
const rawUrl = artifactContentUrl(workspace, runKey, path);
|
|
259
|
+
const downloadUrl = artifactContentUrl(workspace, runKey, path, true);
|
|
260
|
+
const mimeType = content === undefined ? undefined : previewMimeType(content);
|
|
261
|
+
const isMarkdown = mimeType === 'text/markdown';
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<div data-testid="artifact-viewer" className="flex min-h-0 flex-1 flex-col px-[26px] py-[18px]">
|
|
265
|
+
<div className="flex items-center gap-2.5 pb-3">
|
|
266
|
+
<span className="truncate text-[12px] font-bold text-doom-hi">{path}</span>
|
|
267
|
+
<Badge size="xs">{mimeType ?? 'artifact'}</Badge>
|
|
268
|
+
<span className="text-[9px] text-doom-faint">
|
|
269
|
+
{content === undefined ? '' : `${formatSize(content.size)} · written ${formatWhen(content.modifiedAt)}`}
|
|
270
|
+
</span>
|
|
271
|
+
<span className="min-w-0 flex-1" />
|
|
272
|
+
{isMarkdown ? (
|
|
273
|
+
<div className="flex items-center">
|
|
274
|
+
<Button
|
|
275
|
+
variant={raw ? 'outline' : 'primary'}
|
|
276
|
+
size="xs"
|
|
277
|
+
data-testid="artifact-rendered-toggle"
|
|
278
|
+
onClick={() => setRaw(false)}
|
|
279
|
+
>
|
|
280
|
+
rendered
|
|
281
|
+
</Button>
|
|
282
|
+
<Button
|
|
283
|
+
variant={raw ? 'primary' : 'outline'}
|
|
284
|
+
size="xs"
|
|
285
|
+
data-testid="artifact-raw-toggle"
|
|
286
|
+
onClick={() => setRaw(true)}
|
|
287
|
+
>
|
|
288
|
+
raw
|
|
289
|
+
</Button>
|
|
290
|
+
</div>
|
|
291
|
+
) : null}
|
|
292
|
+
<Button asChild variant="ghost" size="xs">
|
|
293
|
+
<a data-testid="artifact-download" href={downloadUrl} download>
|
|
294
|
+
download
|
|
295
|
+
</a>
|
|
296
|
+
</Button>
|
|
297
|
+
<Button
|
|
298
|
+
variant="ghost"
|
|
299
|
+
size="xs"
|
|
300
|
+
data-testid="artifact-reload"
|
|
301
|
+
onClick={() => setReloads((count) => count + 1)}
|
|
302
|
+
>
|
|
303
|
+
reload
|
|
304
|
+
</Button>
|
|
305
|
+
</div>
|
|
306
|
+
<div className="min-h-0 flex-1 overflow-auto rounded-md border border-doom-border bg-doom-deep p-4">
|
|
307
|
+
{error !== undefined ? (
|
|
308
|
+
<span data-testid="artifact-error" className="text-[10px] text-doom-yellow">
|
|
309
|
+
{error}
|
|
310
|
+
</span>
|
|
311
|
+
) : content === undefined ? (
|
|
312
|
+
<span className="text-[10px] text-doom-faint">loading preview</span>
|
|
313
|
+
) : (
|
|
314
|
+
<ArtifactPreview content={content} rawUrl={rawUrl} raw={raw} />
|
|
315
|
+
)}
|
|
316
|
+
</div>
|
|
317
|
+
{content?.truncated === true ? (
|
|
318
|
+
<span className="pt-2 text-[9px] text-doom-faint">
|
|
319
|
+
this preview shows the first {formatSize(content.text?.length)}; download the artifact for the complete file
|
|
320
|
+
</span>
|
|
321
|
+
) : null}
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The run directory, as the run pane's second tab.
|
|
328
|
+
*
|
|
329
|
+
* The workflow's own declaration leads, in the order it was written and
|
|
330
|
+
* including entries no job has produced yet, because the declaration is what
|
|
331
|
+
* the run is for. What the folder holds besides that follows.
|
|
332
|
+
*/
|
|
333
|
+
export function ArtifactsPane({ run, onOpen }: { run: WorkflowRunView; onOpen: (path: string) => void }) {
|
|
334
|
+
const [listing, setListing] = useState<WorkflowArtifactsResponse>();
|
|
335
|
+
const [error, setError] = useState<string>();
|
|
336
|
+
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
let live = true;
|
|
339
|
+
const read = (): void => {
|
|
340
|
+
void fetchArtifacts(run.workspace, run.runKey).then((result) => {
|
|
341
|
+
if (!live) return;
|
|
342
|
+
if ('error' in result) {
|
|
343
|
+
setError(result.error);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
setError(undefined);
|
|
347
|
+
setListing(result.artifacts);
|
|
348
|
+
});
|
|
349
|
+
};
|
|
350
|
+
read();
|
|
351
|
+
// A running workflow writes while the pane is open; a settled one cannot,
|
|
352
|
+
// so it is read once and left alone.
|
|
353
|
+
if (run.stage !== 'running') return;
|
|
354
|
+
const timer = setInterval(read, 5_000);
|
|
355
|
+
return () => {
|
|
356
|
+
live = false;
|
|
357
|
+
clearInterval(timer);
|
|
358
|
+
};
|
|
359
|
+
}, [run.workspace, run.runKey, run.stage]);
|
|
360
|
+
|
|
361
|
+
const declared = listing?.artifacts.filter((entry) => entry.declared) ?? [];
|
|
362
|
+
const found = listing?.artifacts.filter((entry) => !entry.declared) ?? [];
|
|
363
|
+
|
|
364
|
+
const Row = ({ entry }: { entry: WorkflowArtifactView }) => {
|
|
365
|
+
const glyph = STATE_GLYPH[entry.state];
|
|
366
|
+
const openable = entry.kind === 'file' && entry.state !== 'pending';
|
|
367
|
+
return (
|
|
368
|
+
<button
|
|
369
|
+
type="button"
|
|
370
|
+
data-testid={`artifact-row-${entry.path}`}
|
|
371
|
+
data-artifact-state={entry.state}
|
|
372
|
+
disabled={!openable}
|
|
373
|
+
onClick={() => onOpen(entry.path)}
|
|
374
|
+
className={`flex flex-col gap-0.5 px-3 py-1.5 text-left ${openable ? 'cursor-pointer hover:bg-doom-panel' : 'cursor-default'}`}
|
|
375
|
+
>
|
|
376
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
377
|
+
<span className={`w-3 shrink-0 text-[10px] ${glyph.className}`}>{glyph.glyph}</span>
|
|
378
|
+
<span className={`truncate text-[11px] ${entry.state === 'pending' ? 'text-doom-dim' : 'text-doom-hi'}`}>
|
|
379
|
+
{entry.path}
|
|
380
|
+
</span>
|
|
381
|
+
{entry.producedBy.length === 0 ? null : (
|
|
382
|
+
<Badge size="xs" tone="violet" className="shrink-0">
|
|
383
|
+
{entry.producedBy.join(', ')}
|
|
384
|
+
</Badge>
|
|
385
|
+
)}
|
|
386
|
+
<span className="min-w-0 flex-1" />
|
|
387
|
+
<span className="shrink-0 text-[9px] text-doom-faint">
|
|
388
|
+
{entry.state === 'pending'
|
|
389
|
+
? 'not written yet'
|
|
390
|
+
: `${formatSize(entry.size)} · ${formatWhen(entry.modifiedAt)}`}
|
|
391
|
+
</span>
|
|
392
|
+
</span>
|
|
393
|
+
{entry.description === '' ? null : (
|
|
394
|
+
<span className="truncate pl-5 text-[9px] text-doom-faint">{entry.description}</span>
|
|
395
|
+
)}
|
|
396
|
+
</button>
|
|
397
|
+
);
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
return (
|
|
401
|
+
<div data-testid="artifacts-pane" className="flex min-h-0 flex-1 flex-col">
|
|
402
|
+
{error !== undefined ? (
|
|
403
|
+
<EmptyState className="py-4" title="the run directory cannot be read" description={error} />
|
|
404
|
+
) : null}
|
|
405
|
+
{declared.length === 0 && found.length === 0 && error === undefined ? (
|
|
406
|
+
<EmptyState className="py-4" title="nothing in the run directory yet" />
|
|
407
|
+
) : null}
|
|
408
|
+
{declared.length === 0 ? null : (
|
|
409
|
+
<>
|
|
410
|
+
<span className="px-3 pb-1 pt-2 text-[9px] font-bold tracking-[0.14em] text-doom-faint">
|
|
411
|
+
DECLARED · {listing?.description || 'run-directory'}
|
|
412
|
+
</span>
|
|
413
|
+
{declared.map((entry) => (
|
|
414
|
+
<Row key={entry.path} entry={entry} />
|
|
415
|
+
))}
|
|
416
|
+
</>
|
|
417
|
+
)}
|
|
418
|
+
{found.length === 0 ? null : (
|
|
419
|
+
<>
|
|
420
|
+
<span className="px-3 pb-1 pt-3 text-[9px] font-bold tracking-[0.14em] text-doom-faint">
|
|
421
|
+
ALSO IN THE FOLDER
|
|
422
|
+
</span>
|
|
423
|
+
{found.map((entry) => (
|
|
424
|
+
<Row key={entry.path} entry={entry} />
|
|
425
|
+
))}
|
|
426
|
+
</>
|
|
427
|
+
)}
|
|
428
|
+
{listing === undefined ? null : (
|
|
429
|
+
<span className="truncate px-3 py-2 text-[9px] text-doom-faint">{listing.runDir}</span>
|
|
430
|
+
)}
|
|
431
|
+
</div>
|
|
432
|
+
);
|
|
433
|
+
}
|