@agimon-ai/doompi-workflow 0.0.1-alpha.57 → 0.0.1-alpha.58
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 +2 -2
- package/dist/types/webWorkflowTerminal.cjs +1 -1
- package/dist/types/webWorkflowTerminal.cjs.map +1 -1
- package/dist/types/webWorkflowTerminal.mjs +1 -1
- package/dist/types/webWorkflowTerminal.mjs.map +1 -1
- package/package.json +6 -6
- package/src/types/webWorkflowTerminal.ts +4 -0
- package/src/web/ArtifactsPane.tsx +16 -7
- package/src/web/StepTerminalPanel.tsx +18 -13
- package/src/web/WorkflowsPanel.tsx +23 -8
- package/src/web/terminalApi.ts +69 -17
package/README.md
CHANGED
|
@@ -16,8 +16,8 @@ host-executed steps, while DoomPi provides session-scoped tools and TUI surfaces
|
|
|
16
16
|
|
|
17
17
|
## Install
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
enabled. For standalone Pi:
|
|
19
|
+
The seeded `modes.yaml` selects Workflow in `default.packages`. Keep it there or select it through
|
|
20
|
+
a layer; its tools remain inactive until Workflow mode is enabled. For standalone Pi:
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
23
|
pi install npm:@agimon-ai/doompi-workflow
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
exports.WORKFLOW_API_BASE_PATH=`workflow`,exports.WORKFLOW_SCREEN_EVENT=`screen`;
|
|
1
|
+
require("@agimon-ai/doompi-extension-contracts/package-api"),exports.WORKFLOW_API_BASE_PATH=`workflow`,exports.WORKFLOW_SCREEN_EVENT=`screen`;
|
|
2
2
|
//# sourceMappingURL=webWorkflowTerminal.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webWorkflowTerminal.cjs","names":[],"sources":["../../src/types/webWorkflowTerminal.ts"],"sourcesContent":["/**\n * The wire contract between this package's hub API and its cockpit client:\n * one run's terminal, and the files its run directory holds.\n *\n * Both halves of the cockpit import these names, so the route strings and the\n * event names live here rather than being spelled twice.\n */\n\n/** Segment this package's API is mounted under, below /api/plugin/. */\nexport const WORKFLOW_API_BASE_PATH = 'workflow';\n\n/** The SSE event name the screen stream writes. */\nexport const WORKFLOW_SCREEN_EVENT = 'screen';\n\n/** Where a run's routes live, below the mount every hub-scoped package API shares. */\nexport function workflowRunPath(workspace: string, runKey: string): string {\n return `/api/plugin/${WORKFLOW_API_BASE_PATH}/runs/${encodeURIComponent(workspace)}/${encodeURIComponent(runKey)}`;\n}\n\n/** What a surface may do with one run's terminal, and why anything is missing. */\nexport interface WorkflowTerminalCapabilitiesView {\n readable: boolean;\n writable: boolean;\n resizable: boolean;\n reason?: string;\n}\n\n/** One frame of a run's terminal, as the stream pushes it. */\nexport interface WorkflowScreenEvent {\n /** The visible screen, newest line last, carrying the colour the run printed. */\n lines: string[];\n capabilities: WorkflowTerminalCapabilitiesView;\n /** True once the run has settled, after which the stream ends. */\n ended?: boolean;\n}\n\n/** Answer to taking or releasing the keyboard for one run. */\nexport interface WorkflowControlResponse {\n /** True when this caller now holds the keyboard. */\n held: boolean;\n /** The token later writes must carry; absent when control was refused or released. */\n token?: string;\n /** Why control was refused, worded for a reader. */\n reason?: string;\n}\n\n/** Answer after a settled run and its directory have been permanently removed. */\nexport interface WorkflowDeleteResponse {\n deleted: true;\n}\n\n/** How a declared run-directory entry compares with what is on disk. */\nexport type WorkflowArtifactState = 'written' | 'empty' | 'pending' | 'unreadable';\n\n/** One file or directory in a run's own folder. */\nexport interface WorkflowArtifactView {\n /** Path relative to the run directory, which is also the read route's parameter. */\n path: string;\n kind: 'file' | 'directory';\n /** What the workflow says this file is for; empty for a file it never declared. */\n description: string;\n /** The jobs the workflow says write it. */\n producedBy: string[];\n /** True when the workflow's run-directory block names this entry. */\n declared: boolean;\n state: WorkflowArtifactState;\n /** Bytes on disk, absent until the file exists. */\n size?: number;\n /** ISO 8601 of the last write, absent until the file exists. */\n modifiedAt?: string;\n}\n\nexport interface WorkflowArtifactsResponse {\n /** Absolute path of the run's own directory, which the reader may want to open. */\n runDir: string;\n /** What the workflow says the directory is for. */\n description: string;\n artifacts: WorkflowArtifactView[];\n}\n\n/** One artifact's metadata and optional text, as the viewer tab reads it. */\nexport interface WorkflowArtifactContentResponse {\n path: string;\n size: number;\n modifiedAt: string;\n /** Media type inferred from the file extension by the trusted hub. Absent on older hubs. */\n mimeType?: string;\n /** Present for text formats; binary files are streamed from the raw route instead. */\n text?: string;\n /** True when textual content was longer than the reader's byte budget. */\n truncated: boolean;\n}\n"],"mappings":""}
|
|
1
|
+
{"version":3,"file":"webWorkflowTerminal.cjs","names":[],"sources":["../../src/types/webWorkflowTerminal.ts"],"sourcesContent":["/**\n * The wire contract between this package's hub API and its cockpit client:\n * one run's terminal, and the files its run directory holds.\n *\n * Both halves of the cockpit import these names, so the route strings and the\n * event names live here rather than being spelled twice.\n */\n\nimport { DOOM_HUB_API_SESSION_QUERY_PARAM } from '@agimon-ai/doompi-extension-contracts/package-api';\n\n/** Segment this package's API is mounted under, below /api/plugin/. */\nexport const WORKFLOW_API_BASE_PATH = 'workflow';\n/** Host query parameter that selects the focused session's hub API bundle. */\nexport const WORKFLOW_HUB_SESSION_QUERY_PARAM = DOOM_HUB_API_SESSION_QUERY_PARAM;\n\n/** The SSE event name the screen stream writes. */\nexport const WORKFLOW_SCREEN_EVENT = 'screen';\n\n/** Where a run's routes live, below the mount every hub-scoped package API shares. */\nexport function workflowRunPath(workspace: string, runKey: string): string {\n return `/api/plugin/${WORKFLOW_API_BASE_PATH}/runs/${encodeURIComponent(workspace)}/${encodeURIComponent(runKey)}`;\n}\n\n/** What a surface may do with one run's terminal, and why anything is missing. */\nexport interface WorkflowTerminalCapabilitiesView {\n readable: boolean;\n writable: boolean;\n resizable: boolean;\n reason?: string;\n}\n\n/** One frame of a run's terminal, as the stream pushes it. */\nexport interface WorkflowScreenEvent {\n /** The visible screen, newest line last, carrying the colour the run printed. */\n lines: string[];\n capabilities: WorkflowTerminalCapabilitiesView;\n /** True once the run has settled, after which the stream ends. */\n ended?: boolean;\n}\n\n/** Answer to taking or releasing the keyboard for one run. */\nexport interface WorkflowControlResponse {\n /** True when this caller now holds the keyboard. */\n held: boolean;\n /** The token later writes must carry; absent when control was refused or released. */\n token?: string;\n /** Why control was refused, worded for a reader. */\n reason?: string;\n}\n\n/** Answer after a settled run and its directory have been permanently removed. */\nexport interface WorkflowDeleteResponse {\n deleted: true;\n}\n\n/** How a declared run-directory entry compares with what is on disk. */\nexport type WorkflowArtifactState = 'written' | 'empty' | 'pending' | 'unreadable';\n\n/** One file or directory in a run's own folder. */\nexport interface WorkflowArtifactView {\n /** Path relative to the run directory, which is also the read route's parameter. */\n path: string;\n kind: 'file' | 'directory';\n /** What the workflow says this file is for; empty for a file it never declared. */\n description: string;\n /** The jobs the workflow says write it. */\n producedBy: string[];\n /** True when the workflow's run-directory block names this entry. */\n declared: boolean;\n state: WorkflowArtifactState;\n /** Bytes on disk, absent until the file exists. */\n size?: number;\n /** ISO 8601 of the last write, absent until the file exists. */\n modifiedAt?: string;\n}\n\nexport interface WorkflowArtifactsResponse {\n /** Absolute path of the run's own directory, which the reader may want to open. */\n runDir: string;\n /** What the workflow says the directory is for. */\n description: string;\n artifacts: WorkflowArtifactView[];\n}\n\n/** One artifact's metadata and optional text, as the viewer tab reads it. */\nexport interface WorkflowArtifactContentResponse {\n path: string;\n size: number;\n modifiedAt: string;\n /** Media type inferred from the file extension by the trusted hub. Absent on older hubs. */\n mimeType?: string;\n /** Present for text formats; binary files are streamed from the raw route instead. */\n text?: string;\n /** True when textual content was longer than the reader's byte budget. */\n truncated: boolean;\n}\n"],"mappings":""}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=`workflow`,t=`screen`;export{e as WORKFLOW_API_BASE_PATH,t as WORKFLOW_SCREEN_EVENT};
|
|
1
|
+
import"@agimon-ai/doompi-extension-contracts/package-api";const e=`workflow`,t=`screen`;export{e as WORKFLOW_API_BASE_PATH,t as WORKFLOW_SCREEN_EVENT};
|
|
2
2
|
//# sourceMappingURL=webWorkflowTerminal.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webWorkflowTerminal.mjs","names":[],"sources":["../../src/types/webWorkflowTerminal.ts"],"sourcesContent":["/**\n * The wire contract between this package's hub API and its cockpit client:\n * one run's terminal, and the files its run directory holds.\n *\n * Both halves of the cockpit import these names, so the route strings and the\n * event names live here rather than being spelled twice.\n */\n\n/** Segment this package's API is mounted under, below /api/plugin/. */\nexport const WORKFLOW_API_BASE_PATH = 'workflow';\n\n/** The SSE event name the screen stream writes. */\nexport const WORKFLOW_SCREEN_EVENT = 'screen';\n\n/** Where a run's routes live, below the mount every hub-scoped package API shares. */\nexport function workflowRunPath(workspace: string, runKey: string): string {\n return `/api/plugin/${WORKFLOW_API_BASE_PATH}/runs/${encodeURIComponent(workspace)}/${encodeURIComponent(runKey)}`;\n}\n\n/** What a surface may do with one run's terminal, and why anything is missing. */\nexport interface WorkflowTerminalCapabilitiesView {\n readable: boolean;\n writable: boolean;\n resizable: boolean;\n reason?: string;\n}\n\n/** One frame of a run's terminal, as the stream pushes it. */\nexport interface WorkflowScreenEvent {\n /** The visible screen, newest line last, carrying the colour the run printed. */\n lines: string[];\n capabilities: WorkflowTerminalCapabilitiesView;\n /** True once the run has settled, after which the stream ends. */\n ended?: boolean;\n}\n\n/** Answer to taking or releasing the keyboard for one run. */\nexport interface WorkflowControlResponse {\n /** True when this caller now holds the keyboard. */\n held: boolean;\n /** The token later writes must carry; absent when control was refused or released. */\n token?: string;\n /** Why control was refused, worded for a reader. */\n reason?: string;\n}\n\n/** Answer after a settled run and its directory have been permanently removed. */\nexport interface WorkflowDeleteResponse {\n deleted: true;\n}\n\n/** How a declared run-directory entry compares with what is on disk. */\nexport type WorkflowArtifactState = 'written' | 'empty' | 'pending' | 'unreadable';\n\n/** One file or directory in a run's own folder. */\nexport interface WorkflowArtifactView {\n /** Path relative to the run directory, which is also the read route's parameter. */\n path: string;\n kind: 'file' | 'directory';\n /** What the workflow says this file is for; empty for a file it never declared. */\n description: string;\n /** The jobs the workflow says write it. */\n producedBy: string[];\n /** True when the workflow's run-directory block names this entry. */\n declared: boolean;\n state: WorkflowArtifactState;\n /** Bytes on disk, absent until the file exists. */\n size?: number;\n /** ISO 8601 of the last write, absent until the file exists. */\n modifiedAt?: string;\n}\n\nexport interface WorkflowArtifactsResponse {\n /** Absolute path of the run's own directory, which the reader may want to open. */\n runDir: string;\n /** What the workflow says the directory is for. */\n description: string;\n artifacts: WorkflowArtifactView[];\n}\n\n/** One artifact's metadata and optional text, as the viewer tab reads it. */\nexport interface WorkflowArtifactContentResponse {\n path: string;\n size: number;\n modifiedAt: string;\n /** Media type inferred from the file extension by the trusted hub. Absent on older hubs. */\n mimeType?: string;\n /** Present for text formats; binary files are streamed from the raw route instead. */\n text?: string;\n /** True when textual content was longer than the reader's byte budget. */\n truncated: boolean;\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"webWorkflowTerminal.mjs","names":[],"sources":["../../src/types/webWorkflowTerminal.ts"],"sourcesContent":["/**\n * The wire contract between this package's hub API and its cockpit client:\n * one run's terminal, and the files its run directory holds.\n *\n * Both halves of the cockpit import these names, so the route strings and the\n * event names live here rather than being spelled twice.\n */\n\nimport { DOOM_HUB_API_SESSION_QUERY_PARAM } from '@agimon-ai/doompi-extension-contracts/package-api';\n\n/** Segment this package's API is mounted under, below /api/plugin/. */\nexport const WORKFLOW_API_BASE_PATH = 'workflow';\n/** Host query parameter that selects the focused session's hub API bundle. */\nexport const WORKFLOW_HUB_SESSION_QUERY_PARAM = DOOM_HUB_API_SESSION_QUERY_PARAM;\n\n/** The SSE event name the screen stream writes. */\nexport const WORKFLOW_SCREEN_EVENT = 'screen';\n\n/** Where a run's routes live, below the mount every hub-scoped package API shares. */\nexport function workflowRunPath(workspace: string, runKey: string): string {\n return `/api/plugin/${WORKFLOW_API_BASE_PATH}/runs/${encodeURIComponent(workspace)}/${encodeURIComponent(runKey)}`;\n}\n\n/** What a surface may do with one run's terminal, and why anything is missing. */\nexport interface WorkflowTerminalCapabilitiesView {\n readable: boolean;\n writable: boolean;\n resizable: boolean;\n reason?: string;\n}\n\n/** One frame of a run's terminal, as the stream pushes it. */\nexport interface WorkflowScreenEvent {\n /** The visible screen, newest line last, carrying the colour the run printed. */\n lines: string[];\n capabilities: WorkflowTerminalCapabilitiesView;\n /** True once the run has settled, after which the stream ends. */\n ended?: boolean;\n}\n\n/** Answer to taking or releasing the keyboard for one run. */\nexport interface WorkflowControlResponse {\n /** True when this caller now holds the keyboard. */\n held: boolean;\n /** The token later writes must carry; absent when control was refused or released. */\n token?: string;\n /** Why control was refused, worded for a reader. */\n reason?: string;\n}\n\n/** Answer after a settled run and its directory have been permanently removed. */\nexport interface WorkflowDeleteResponse {\n deleted: true;\n}\n\n/** How a declared run-directory entry compares with what is on disk. */\nexport type WorkflowArtifactState = 'written' | 'empty' | 'pending' | 'unreadable';\n\n/** One file or directory in a run's own folder. */\nexport interface WorkflowArtifactView {\n /** Path relative to the run directory, which is also the read route's parameter. */\n path: string;\n kind: 'file' | 'directory';\n /** What the workflow says this file is for; empty for a file it never declared. */\n description: string;\n /** The jobs the workflow says write it. */\n producedBy: string[];\n /** True when the workflow's run-directory block names this entry. */\n declared: boolean;\n state: WorkflowArtifactState;\n /** Bytes on disk, absent until the file exists. */\n size?: number;\n /** ISO 8601 of the last write, absent until the file exists. */\n modifiedAt?: string;\n}\n\nexport interface WorkflowArtifactsResponse {\n /** Absolute path of the run's own directory, which the reader may want to open. */\n runDir: string;\n /** What the workflow says the directory is for. */\n description: string;\n artifacts: WorkflowArtifactView[];\n}\n\n/** One artifact's metadata and optional text, as the viewer tab reads it. */\nexport interface WorkflowArtifactContentResponse {\n path: string;\n size: number;\n modifiedAt: string;\n /** Media type inferred from the file extension by the trusted hub. Absent on older hubs. */\n mimeType?: string;\n /** Present for text formats; binary files are streamed from the raw route instead. */\n text?: string;\n /** True when textual content was longer than the reader's byte budget. */\n truncated: boolean;\n}\n"],"mappings":"0DAWA,MAAa,EAAyB,WAKzB,EAAwB"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agimon-ai/doompi-workflow",
|
|
3
|
-
"version": "0.0.1-alpha.
|
|
3
|
+
"version": "0.0.1-alpha.58",
|
|
4
4
|
"description": "GitHub Actions-style workflow graphs, artifacts, recovery, and asynchronous runs for DoomPi.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -68,12 +68,12 @@
|
|
|
68
68
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
69
69
|
"hono": "4.13.5",
|
|
70
70
|
"zod": "4.4.3",
|
|
71
|
-
"@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.
|
|
72
|
-
"@agimon-ai/doompi-telemetry": "0.0.1-alpha.56",
|
|
73
|
-
"@agimon-ai/doompi-ui": "0.0.1-alpha.57",
|
|
74
|
-
"@agimon-ai/doompi-web-components": "0.0.1-alpha.17",
|
|
71
|
+
"@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.57",
|
|
75
72
|
"@agimon-ai/doompi-web-contracts": "0.0.1-alpha.20",
|
|
76
|
-
"@agimon-ai/doompi-web-
|
|
73
|
+
"@agimon-ai/doompi-web-components": "0.0.1-alpha.17",
|
|
74
|
+
"@agimon-ai/doompi-ui": "0.0.1-alpha.58",
|
|
75
|
+
"@agimon-ai/doompi-telemetry": "0.0.1-alpha.56",
|
|
76
|
+
"@agimon-ai/doompi-web-security": "0.0.1-alpha.19"
|
|
77
77
|
},
|
|
78
78
|
"devDependencies": {
|
|
79
79
|
"@earendil-works/pi-coding-agent": "0.85.0",
|
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
* event names live here rather than being spelled twice.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { DOOM_HUB_API_SESSION_QUERY_PARAM } from '@agimon-ai/doompi-extension-contracts/package-api';
|
|
10
|
+
|
|
9
11
|
/** Segment this package's API is mounted under, below /api/plugin/. */
|
|
10
12
|
export const WORKFLOW_API_BASE_PATH = 'workflow';
|
|
13
|
+
/** Host query parameter that selects the focused session's hub API bundle. */
|
|
14
|
+
export const WORKFLOW_HUB_SESSION_QUERY_PARAM = DOOM_HUB_API_SESSION_QUERY_PARAM;
|
|
11
15
|
|
|
12
16
|
/** The SSE event name the screen stream writes. */
|
|
13
17
|
export const WORKFLOW_SCREEN_EVENT = 'screen';
|
|
@@ -246,6 +246,7 @@ export function ArtifactViewerPanel({
|
|
|
246
246
|
workspace,
|
|
247
247
|
runKey,
|
|
248
248
|
path,
|
|
249
|
+
sessionId,
|
|
249
250
|
}: WebPluginSlotProps & { workspace: string; runKey: string; path: string }) {
|
|
250
251
|
const [content, setContent] = useState<WorkflowArtifactContentResponse>();
|
|
251
252
|
const [error, setError] = useState<string>();
|
|
@@ -254,7 +255,7 @@ export function ArtifactViewerPanel({
|
|
|
254
255
|
|
|
255
256
|
useEffect(() => {
|
|
256
257
|
let live = true;
|
|
257
|
-
void fetchArtifact(workspace, runKey, path).then((result) => {
|
|
258
|
+
void fetchArtifact(workspace, runKey, path, sessionId).then((result) => {
|
|
258
259
|
if (!live) return;
|
|
259
260
|
if ('error' in result) {
|
|
260
261
|
setError(result.error);
|
|
@@ -267,10 +268,10 @@ export function ArtifactViewerPanel({
|
|
|
267
268
|
return () => {
|
|
268
269
|
live = false;
|
|
269
270
|
};
|
|
270
|
-
}, [workspace, runKey, path, reloads]);
|
|
271
|
+
}, [workspace, runKey, path, reloads, sessionId]);
|
|
271
272
|
|
|
272
|
-
const rawUrl = artifactContentUrl(workspace, runKey, path);
|
|
273
|
-
const downloadUrl = artifactContentUrl(workspace, runKey, path, true);
|
|
273
|
+
const rawUrl = artifactContentUrl(workspace, runKey, path, false, sessionId);
|
|
274
|
+
const downloadUrl = artifactContentUrl(workspace, runKey, path, true, sessionId);
|
|
274
275
|
const mimeType = content === undefined ? undefined : previewMimeType(content);
|
|
275
276
|
const isMarkdown = mimeType === 'text/markdown';
|
|
276
277
|
|
|
@@ -344,14 +345,22 @@ export function ArtifactViewerPanel({
|
|
|
344
345
|
* including entries no job has produced yet, because the declaration is what
|
|
345
346
|
* the run is for. What the folder holds besides that follows.
|
|
346
347
|
*/
|
|
347
|
-
export function ArtifactsPane({
|
|
348
|
+
export function ArtifactsPane({
|
|
349
|
+
run,
|
|
350
|
+
sessionId,
|
|
351
|
+
onOpen,
|
|
352
|
+
}: {
|
|
353
|
+
run: WorkflowRunView;
|
|
354
|
+
sessionId: string | null;
|
|
355
|
+
onOpen: (path: string) => void;
|
|
356
|
+
}) {
|
|
348
357
|
const [listing, setListing] = useState<WorkflowArtifactsResponse>();
|
|
349
358
|
const [error, setError] = useState<string>();
|
|
350
359
|
|
|
351
360
|
useEffect(() => {
|
|
352
361
|
let live = true;
|
|
353
362
|
const read = (): void => {
|
|
354
|
-
void fetchArtifacts(run.workspace, run.runKey).then((result) => {
|
|
363
|
+
void fetchArtifacts(run.workspace, run.runKey, sessionId).then((result) => {
|
|
355
364
|
if (!live) return;
|
|
356
365
|
if ('error' in result) {
|
|
357
366
|
setError(result.error);
|
|
@@ -370,7 +379,7 @@ export function ArtifactsPane({ run, onOpen }: { run: WorkflowRunView; onOpen: (
|
|
|
370
379
|
live = false;
|
|
371
380
|
clearInterval(timer);
|
|
372
381
|
};
|
|
373
|
-
}, [run.workspace, run.runKey, run.stage]);
|
|
382
|
+
}, [run.workspace, run.runKey, run.stage, sessionId]);
|
|
374
383
|
|
|
375
384
|
const declared = listing?.artifacts.filter((entry) => entry.declared) ?? [];
|
|
376
385
|
const found = listing?.artifacts.filter((entry) => !entry.declared) ?? [];
|
|
@@ -80,44 +80,49 @@ export function StepTerminalPanel({ sessionId, target }: WebPluginSlotProps & {
|
|
|
80
80
|
// A new run's screen has not ended yet; the subscription below is the external system.
|
|
81
81
|
// oxlint-disable-next-line react/set-state-in-effect
|
|
82
82
|
setEnded(false);
|
|
83
|
-
return followScreen(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
return followScreen(
|
|
84
|
+
target.workspace,
|
|
85
|
+
target.runKey,
|
|
86
|
+
(event) => {
|
|
87
|
+
setLines(event.lines);
|
|
88
|
+
setCapabilities(event.capabilities);
|
|
89
|
+
if (event.ended === true) setEnded(true);
|
|
90
|
+
},
|
|
91
|
+
sessionId,
|
|
92
|
+
);
|
|
93
|
+
}, [sessionId, target.workspace, target.runKey]);
|
|
89
94
|
|
|
90
95
|
// The keyboard is a lease on the hub, so a tab that goes away must hand it
|
|
91
96
|
// back rather than leaving the next reader locked out until it expires.
|
|
92
97
|
useEffect(() => {
|
|
93
98
|
if (token === undefined) return;
|
|
94
99
|
return () => {
|
|
95
|
-
void releaseControl(target.workspace, target.runKey, token);
|
|
100
|
+
void releaseControl(target.workspace, target.runKey, token, sessionId);
|
|
96
101
|
};
|
|
97
|
-
}, [token, target.workspace, target.runKey]);
|
|
102
|
+
}, [sessionId, token, target.workspace, target.runKey]);
|
|
98
103
|
|
|
99
104
|
useEffect(() => {
|
|
100
105
|
screenRef.current?.scrollTo({ top: screenRef.current.scrollHeight });
|
|
101
106
|
}, [lines]);
|
|
102
107
|
|
|
103
108
|
const arm = useCallback(async () => {
|
|
104
|
-
const result = await takeControl(target.workspace, target.runKey);
|
|
109
|
+
const result = await takeControl(target.workspace, target.runKey, undefined, sessionId);
|
|
105
110
|
if (result.held && result.token !== undefined) {
|
|
106
111
|
setToken(result.token);
|
|
107
112
|
setNotice(undefined);
|
|
108
113
|
return;
|
|
109
114
|
}
|
|
110
115
|
setNotice(result.reason ?? 'The keyboard is not available for this run.');
|
|
111
|
-
}, [target.workspace, target.runKey]);
|
|
116
|
+
}, [sessionId, target.workspace, target.runKey]);
|
|
112
117
|
|
|
113
118
|
const disarm = useCallback(async () => {
|
|
114
|
-
if (token !== undefined) await releaseControl(target.workspace, target.runKey, token);
|
|
119
|
+
if (token !== undefined) await releaseControl(target.workspace, target.runKey, token, sessionId);
|
|
115
120
|
setToken(undefined);
|
|
116
|
-
}, [token, target.workspace, target.runKey]);
|
|
121
|
+
}, [sessionId, token, target.workspace, target.runKey]);
|
|
117
122
|
|
|
118
123
|
const type = async (data: string): Promise<void> => {
|
|
119
124
|
if (token === undefined) return;
|
|
120
|
-
const { error } = await sendKeys(target.workspace, target.runKey, token, data);
|
|
125
|
+
const { error } = await sendKeys(target.workspace, target.runKey, token, data, sessionId);
|
|
121
126
|
if (error !== undefined) {
|
|
122
127
|
setNotice(error);
|
|
123
128
|
setToken(undefined);
|
|
@@ -344,11 +344,13 @@ function InlineStepOutput({
|
|
|
344
344
|
run,
|
|
345
345
|
job,
|
|
346
346
|
step,
|
|
347
|
+
sessionId,
|
|
347
348
|
onOpenTerminal,
|
|
348
349
|
}: {
|
|
349
350
|
run: WorkflowRunView;
|
|
350
351
|
job: WorkflowJobView;
|
|
351
352
|
step: WorkflowStepView | undefined;
|
|
353
|
+
sessionId: string | null;
|
|
352
354
|
onOpenTerminal: () => void;
|
|
353
355
|
}) {
|
|
354
356
|
const [lines, setLines] = useState<string[]>([]);
|
|
@@ -363,12 +365,17 @@ function InlineStepOutput({
|
|
|
363
365
|
setLines([]);
|
|
364
366
|
setCapabilities(undefined);
|
|
365
367
|
setEnded(false);
|
|
366
|
-
return followScreen(
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
368
|
+
return followScreen(
|
|
369
|
+
run.workspace,
|
|
370
|
+
run.runKey,
|
|
371
|
+
(event) => {
|
|
372
|
+
setLines(event.lines);
|
|
373
|
+
setCapabilities(event.capabilities);
|
|
374
|
+
if (event.ended === true) setEnded(true);
|
|
375
|
+
},
|
|
376
|
+
sessionId,
|
|
377
|
+
);
|
|
378
|
+
}, [run.workspace, run.runKey, sessionId]);
|
|
372
379
|
|
|
373
380
|
useEffect(() => {
|
|
374
381
|
screenRef.current?.scrollTo({ top: screenRef.current.scrollHeight });
|
|
@@ -415,10 +422,12 @@ function InlineStepOutput({
|
|
|
415
422
|
|
|
416
423
|
function DeleteWorkflowDialog({
|
|
417
424
|
run,
|
|
425
|
+
sessionId,
|
|
418
426
|
onClose,
|
|
419
427
|
onDeleted,
|
|
420
428
|
}: {
|
|
421
429
|
run: WorkflowRunView;
|
|
430
|
+
sessionId: string;
|
|
422
431
|
onClose: () => void;
|
|
423
432
|
onDeleted: () => void;
|
|
424
433
|
}) {
|
|
@@ -428,7 +437,7 @@ function DeleteWorkflowDialog({
|
|
|
428
437
|
const confirm = (): void => {
|
|
429
438
|
setDeleting(true);
|
|
430
439
|
setError(undefined);
|
|
431
|
-
void deleteWorkflowRun(run.workspace, run.runKey).then((result) => {
|
|
440
|
+
void deleteWorkflowRun(run.workspace, run.runKey, sessionId).then((result) => {
|
|
432
441
|
if ('error' in result) {
|
|
433
442
|
setDeleting(false);
|
|
434
443
|
setError(result.error);
|
|
@@ -681,7 +690,11 @@ export function WorkflowsPanel({ sessionId, openTransientTab, sendSessionFrame }
|
|
|
681
690
|
</Button>
|
|
682
691
|
</div>
|
|
683
692
|
{pane === 'artifacts' ? (
|
|
684
|
-
<ArtifactsPane
|
|
693
|
+
<ArtifactsPane
|
|
694
|
+
run={run}
|
|
695
|
+
sessionId={sessionId}
|
|
696
|
+
onOpen={(path) => openTransientTab(artifactTab(run, path))}
|
|
697
|
+
/>
|
|
685
698
|
) : job === undefined ? (
|
|
686
699
|
<EmptyState className="py-6" title="select a job to see its output" />
|
|
687
700
|
) : (
|
|
@@ -689,6 +702,7 @@ export function WorkflowsPanel({ sessionId, openTransientTab, sendSessionFrame }
|
|
|
689
702
|
run={run}
|
|
690
703
|
job={job}
|
|
691
704
|
step={step}
|
|
705
|
+
sessionId={sessionId}
|
|
692
706
|
onOpenTerminal={() => openTransientTab(stepTerminalTab(run, job.name, step?.name))}
|
|
693
707
|
/>
|
|
694
708
|
)}
|
|
@@ -721,6 +735,7 @@ export function WorkflowsPanel({ sessionId, openTransientTab, sendSessionFrame }
|
|
|
721
735
|
{sessionId !== null && deletingRun !== undefined ? (
|
|
722
736
|
<DeleteWorkflowDialog
|
|
723
737
|
run={deletingRun}
|
|
738
|
+
sessionId={sessionId}
|
|
724
739
|
onClose={() => setDeletingRun(undefined)}
|
|
725
740
|
onDeleted={() => {
|
|
726
741
|
removeRun(sessionId, workflowRunIdentity(deletingRun));
|
package/src/web/terminalApi.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sealedTransport } from '@agimon-ai/doompi-web-security/browser';
|
|
2
2
|
import {
|
|
3
|
+
WORKFLOW_HUB_SESSION_QUERY_PARAM,
|
|
3
4
|
WORKFLOW_SCREEN_EVENT,
|
|
4
5
|
workflowRunPath,
|
|
5
6
|
type WorkflowArtifactContentResponse,
|
|
@@ -18,6 +19,11 @@ import {
|
|
|
18
19
|
const UNREACHABLE = 'The cockpit hub is unreachable.';
|
|
19
20
|
const JSON_HEADERS = { 'content-type': 'application/json' };
|
|
20
21
|
|
|
22
|
+
function sessionUrl(path: string, sessionId?: string | null): string {
|
|
23
|
+
if (sessionId === undefined || sessionId === null) return path;
|
|
24
|
+
const separator = path.includes('?') ? '&' : '?';
|
|
25
|
+
return `${path}${separator}${WORKFLOW_HUB_SESSION_QUERY_PARAM}=${encodeURIComponent(sessionId)}`;
|
|
26
|
+
}
|
|
21
27
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
22
28
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
23
29
|
}
|
|
@@ -27,9 +33,13 @@ function errorOf(body: unknown, fallback: string): string {
|
|
|
27
33
|
return isRecord(body) && typeof body.error === 'string' && body.error !== '' ? body.error : fallback;
|
|
28
34
|
}
|
|
29
35
|
|
|
30
|
-
async function post(
|
|
36
|
+
async function post(
|
|
37
|
+
path: string,
|
|
38
|
+
body: unknown,
|
|
39
|
+
sessionId?: string | null,
|
|
40
|
+
): Promise<{ status: number; body: unknown }> {
|
|
31
41
|
try {
|
|
32
|
-
const response = await sealedTransport.fetch(path, {
|
|
42
|
+
const response = await sealedTransport.fetch(sessionUrl(path, sessionId), {
|
|
33
43
|
method: 'POST',
|
|
34
44
|
headers: JSON_HEADERS,
|
|
35
45
|
body: JSON.stringify(body),
|
|
@@ -51,8 +61,9 @@ export function followScreen(
|
|
|
51
61
|
workspace: string,
|
|
52
62
|
runKey: string,
|
|
53
63
|
onEvent: (event: WorkflowScreenEvent) => void,
|
|
64
|
+
sessionId?: string | null,
|
|
54
65
|
): () => void {
|
|
55
|
-
const source = new EventSource(`${workflowRunPath(workspace, runKey)}/screen/stream
|
|
66
|
+
const source = new EventSource(sessionUrl(`${workflowRunPath(workspace, runKey)}/screen/stream`, sessionId));
|
|
56
67
|
const handler = (message: MessageEvent<string>): void => {
|
|
57
68
|
let parsed: unknown;
|
|
58
69
|
try {
|
|
@@ -71,15 +82,25 @@ export function followScreen(
|
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
/** Takes the keyboard for one run, renewing when this page already holds it. */
|
|
74
|
-
export async function takeControl(
|
|
75
|
-
|
|
85
|
+
export async function takeControl(
|
|
86
|
+
workspace: string,
|
|
87
|
+
runKey: string,
|
|
88
|
+
token?: string,
|
|
89
|
+
sessionId?: string | null,
|
|
90
|
+
): Promise<WorkflowControlResponse> {
|
|
91
|
+
const { status, body } = await post(`${workflowRunPath(workspace, runKey)}/control`, { token }, sessionId);
|
|
76
92
|
if (status === 0) return { held: false, reason: UNREACHABLE };
|
|
77
93
|
if (isRecord(body) && typeof body.held === 'boolean') return body as unknown as WorkflowControlResponse;
|
|
78
94
|
return { held: false, reason: errorOf(body, 'The run refused the keyboard.') };
|
|
79
95
|
}
|
|
80
96
|
|
|
81
|
-
export async function releaseControl(
|
|
82
|
-
|
|
97
|
+
export async function releaseControl(
|
|
98
|
+
workspace: string,
|
|
99
|
+
runKey: string,
|
|
100
|
+
token: string,
|
|
101
|
+
sessionId?: string | null,
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
await post(`${workflowRunPath(workspace, runKey)}/control`, { token, release: true }, sessionId);
|
|
83
104
|
}
|
|
84
105
|
|
|
85
106
|
/** Sends literal keystrokes; the reason comes back when the lease has moved on. */
|
|
@@ -88,8 +109,9 @@ export async function sendKeys(
|
|
|
88
109
|
runKey: string,
|
|
89
110
|
token: string,
|
|
90
111
|
data: string,
|
|
112
|
+
sessionId?: string | null,
|
|
91
113
|
): Promise<{ error?: string }> {
|
|
92
|
-
const { status, body } = await post(`${workflowRunPath(workspace, runKey)}/keys`, { token, data });
|
|
114
|
+
const { status, body } = await post(`${workflowRunPath(workspace, runKey)}/keys`, { token, data }, sessionId);
|
|
93
115
|
if (status === 0) return { error: UNREACHABLE };
|
|
94
116
|
if (status === 204) return {};
|
|
95
117
|
return { error: errorOf(body, 'The run would not take those keys.') };
|
|
@@ -102,16 +124,23 @@ export async function resizeRun(
|
|
|
102
124
|
token: string,
|
|
103
125
|
columns: number,
|
|
104
126
|
rows: number,
|
|
127
|
+
sessionId?: string | null,
|
|
105
128
|
): Promise<void> {
|
|
106
|
-
await post(`${workflowRunPath(workspace, runKey)}/resize`, { token, columns, rows });
|
|
129
|
+
await post(`${workflowRunPath(workspace, runKey)}/resize`, { token, columns, rows }, sessionId);
|
|
107
130
|
}
|
|
108
131
|
|
|
109
132
|
export type DeleteWorkflowResult = { result: WorkflowDeleteResponse } | { error: string };
|
|
110
133
|
|
|
111
134
|
/** Permanently removes one settled run and its run directory. */
|
|
112
|
-
export async function deleteWorkflowRun(
|
|
135
|
+
export async function deleteWorkflowRun(
|
|
136
|
+
workspace: string,
|
|
137
|
+
runKey: string,
|
|
138
|
+
sessionId?: string | null,
|
|
139
|
+
): Promise<DeleteWorkflowResult> {
|
|
113
140
|
try {
|
|
114
|
-
const response = await sealedTransport.fetch(workflowRunPath(workspace, runKey), {
|
|
141
|
+
const response = await sealedTransport.fetch(sessionUrl(workflowRunPath(workspace, runKey), sessionId), {
|
|
142
|
+
method: 'DELETE',
|
|
143
|
+
});
|
|
115
144
|
const body = (await response.json()) as unknown;
|
|
116
145
|
if (!response.ok) return { error: errorOf(body, 'The workflow could not be deleted.') };
|
|
117
146
|
if (isRecord(body) && body.deleted === true) return { result: { deleted: true } };
|
|
@@ -123,9 +152,15 @@ export async function deleteWorkflowRun(workspace: string, runKey: string): Prom
|
|
|
123
152
|
|
|
124
153
|
export type ArtifactsResult = { artifacts: WorkflowArtifactsResponse } | { error: string };
|
|
125
154
|
|
|
126
|
-
export async function fetchArtifacts(
|
|
155
|
+
export async function fetchArtifacts(
|
|
156
|
+
workspace: string,
|
|
157
|
+
runKey: string,
|
|
158
|
+
sessionId?: string | null,
|
|
159
|
+
): Promise<ArtifactsResult> {
|
|
127
160
|
try {
|
|
128
|
-
const response = await sealedTransport.fetch(
|
|
161
|
+
const response = await sealedTransport.fetch(
|
|
162
|
+
sessionUrl(`${workflowRunPath(workspace, runKey)}/artifacts`, sessionId),
|
|
163
|
+
);
|
|
129
164
|
const body = (await response.json()) as unknown;
|
|
130
165
|
if (!response.ok) return { error: errorOf(body, 'This run has no directory to read.') };
|
|
131
166
|
return { artifacts: body as WorkflowArtifactsResponse };
|
|
@@ -137,15 +172,32 @@ export async function fetchArtifacts(workspace: string, runKey: string): Promise
|
|
|
137
172
|
export type ArtifactResult = { artifact: WorkflowArtifactContentResponse } | { error: string };
|
|
138
173
|
|
|
139
174
|
/** Trusted same-origin URL for browser-native media previews and downloads. */
|
|
140
|
-
export function artifactContentUrl(
|
|
175
|
+
export function artifactContentUrl(
|
|
176
|
+
workspace: string,
|
|
177
|
+
runKey: string,
|
|
178
|
+
path: string,
|
|
179
|
+
download = false,
|
|
180
|
+
sessionId?: string | null,
|
|
181
|
+
): string {
|
|
141
182
|
const encodedPath = path.split('/').map(encodeURIComponent).join('/');
|
|
142
|
-
return
|
|
183
|
+
return sessionUrl(
|
|
184
|
+
`${workflowRunPath(workspace, runKey)}/artifacts/${encodedPath}?raw=1${download ? '&download=1' : ''}`,
|
|
185
|
+
sessionId,
|
|
186
|
+
);
|
|
143
187
|
}
|
|
144
188
|
|
|
145
|
-
export async function fetchArtifact(
|
|
189
|
+
export async function fetchArtifact(
|
|
190
|
+
workspace: string,
|
|
191
|
+
runKey: string,
|
|
192
|
+
path: string,
|
|
193
|
+
sessionId?: string | null,
|
|
194
|
+
): Promise<ArtifactResult> {
|
|
146
195
|
try {
|
|
147
196
|
const response = await sealedTransport.fetch(
|
|
148
|
-
|
|
197
|
+
sessionUrl(
|
|
198
|
+
`${workflowRunPath(workspace, runKey)}/artifacts/${path.split('/').map(encodeURIComponent).join('/')}`,
|
|
199
|
+
sessionId,
|
|
200
|
+
),
|
|
149
201
|
);
|
|
150
202
|
const body = (await response.json()) as unknown;
|
|
151
203
|
if (!response.ok) return { error: errorOf(body, 'That file has not been written.') };
|