@agimon-ai/doompi-workflow 0.0.1-alpha.56 → 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 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
- DoomPi includes Workflow in every composition; its tools remain inactive until Workflow mode is
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":"AASA,MAAa,EAAyB,WAGzB,EAAwB"}
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.56",
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-telemetry": "0.0.1-alpha.55",
72
- "@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.55",
73
- "@agimon-ai/doompi-web-components": "0.0.1-alpha.16",
74
- "@agimon-ai/doompi-web-contracts": "0.0.1-alpha.19",
75
- "@agimon-ai/doompi-ui": "0.0.1-alpha.56",
76
- "@agimon-ai/doompi-web-security": "0.0.1-alpha.17"
71
+ "@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.57",
72
+ "@agimon-ai/doompi-web-contracts": "0.0.1-alpha.20",
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({ run, onOpen }: { run: WorkflowRunView; onOpen: (path: string) => void }) {
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(target.workspace, target.runKey, (event) => {
84
- setLines(event.lines);
85
- setCapabilities(event.capabilities);
86
- if (event.ended === true) setEnded(true);
87
- });
88
- }, [target.workspace, target.runKey]);
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(run.workspace, run.runKey, (event) => {
367
- setLines(event.lines);
368
- setCapabilities(event.capabilities);
369
- if (event.ended === true) setEnded(true);
370
- });
371
- }, [run.workspace, run.runKey]);
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 run={run} onOpen={(path) => openTransientTab(artifactTab(run, path))} />
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));
@@ -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(path: string, body: unknown): Promise<{ status: number; body: unknown }> {
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(workspace: string, runKey: string, token?: string): Promise<WorkflowControlResponse> {
75
- const { status, body } = await post(`${workflowRunPath(workspace, runKey)}/control`, { token });
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(workspace: string, runKey: string, token: string): Promise<void> {
82
- await post(`${workflowRunPath(workspace, runKey)}/control`, { token, release: true });
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(workspace: string, runKey: string): Promise<DeleteWorkflowResult> {
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), { method: 'DELETE' });
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(workspace: string, runKey: string): Promise<ArtifactsResult> {
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(`${workflowRunPath(workspace, runKey)}/artifacts`);
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(workspace: string, runKey: string, path: string, download = false): string {
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 `${workflowRunPath(workspace, runKey)}/artifacts/${encodedPath}?raw=1${download ? '&download=1' : ''}`;
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(workspace: string, runKey: string, path: string): Promise<ArtifactResult> {
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
- `${workflowRunPath(workspace, runKey)}/artifacts/${path.split('/').map(encodeURIComponent).join('/')}`,
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.') };