@lazyingart/agintiflow 0.20.126 → 0.20.128
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 +4 -2
- package/package.json +1 -1
- package/public/app.js +55 -13
- package/public/styles.css +23 -0
- package/scripts/smoke-canvas-artifacts.js +82 -1
- package/scripts/smoke-capabilities.js +2 -0
- package/scripts/smoke-cli-chat.js +2 -0
- package/scripts/smoke-web-api.js +32 -0
- package/scripts/smoke-web-autostart.js +23 -0
- package/scripts/smoke-webapp-command.js +7 -1
- package/src/artifact-tunnel.js +87 -52
- package/src/cli.js +52 -1
- package/src/i18n.js +1 -1
- package/src/interactive-cli.js +8 -4
- package/src/web-autostart.js +165 -15
- package/web.js +61 -1
package/README.md
CHANGED
|
@@ -103,13 +103,15 @@ Provider signup and key pages:
|
|
|
103
103
|
| Qwen / DashScope | [https://bailian.console.aliyun.com/](https://bailian.console.aliyun.com/) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` |
|
|
104
104
|
| GRS AI image tools | [https://grsai.ai/dashboard/api-keys](https://grsai.ai/dashboard/api-keys) | Configure with `/auxiliary grsai` or `aginti login grsai` |
|
|
105
105
|
|
|
106
|
-
The CLI quietly auto-starts or reuses the local web UI from the same project. It tries `http://127.0.0.1:3210` first, then `3211`, `3212`, and so on if the port is already occupied. The active URL is shown in the CLI launch header. If startup is blocked or unavailable, the same header row shows the recovery hint; run `/webapp [port]` inside the CLI to retry.
|
|
106
|
+
The CLI quietly auto-starts or reuses the local web UI from the same project. It tries `http://127.0.0.1:3210` first, then `3211`, `3212`, and so on if the port is already occupied by another project. The active URL is shown in the CLI launch header. If startup is blocked, stale, or unavailable, the same header row shows the recovery hint; run `/webapp [port]` inside the CLI to retry, or `/webapp restart [port]` to stop and relaunch the local webapp with the current project and canonical `~/.agintiflow` session home.
|
|
107
107
|
|
|
108
108
|
Package installation also makes a best-effort, non-blocking webapp initialization. Install never fails because the optional local webapp could not start.
|
|
109
109
|
|
|
110
110
|
Launch the web UI explicitly when you want a foreground web server:
|
|
111
111
|
|
|
112
112
|
```bash
|
|
113
|
+
aginti webapp
|
|
114
|
+
aginti webapp restart
|
|
113
115
|
aginti web --port 3210
|
|
114
116
|
# opens http://127.0.0.1:3210, or the next available port
|
|
115
117
|
```
|
|
@@ -143,7 +145,7 @@ aginti --language de
|
|
|
143
145
|
| Goal | Command |
|
|
144
146
|
| --- | --- |
|
|
145
147
|
| Start interactive chat | `aginti` or `aginti chat` |
|
|
146
|
-
| Start local web app | Auto-starts with `aginti`; foreground mode is `aginti web --port 3210` |
|
|
148
|
+
| Start local web app | Auto-starts with `aginti`; detached command is `aginti webapp`; restart with `aginti webapp restart`; foreground mode is `aginti web --port 3210` |
|
|
147
149
|
| Save provider keys | `aginti auth`, `/auth`, `/login` |
|
|
148
150
|
| Review current repo | `/review [focus]` |
|
|
149
151
|
| Toggle SCS quality gate | `/scs` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lazyingart/agintiflow",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.128",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "AgInTiFlow is a project-aware agent workspace for hybrid wet-dry R&D, hardware-aware intelligence, software automation, and industrial workflows.",
|
|
6
6
|
"license": "Apache-2.0",
|
package/public/app.js
CHANGED
|
@@ -2061,6 +2061,12 @@ function resetArtifactViewer() {
|
|
|
2061
2061
|
artifactViewerBodyEl.innerHTML = `<p class="subtle">${t("artifactViewerEmpty")}</p>`;
|
|
2062
2062
|
}
|
|
2063
2063
|
|
|
2064
|
+
function artifactRawUrl(artifactId, { download = false } = {}) {
|
|
2065
|
+
if (!currentSessionId || !artifactId) return "";
|
|
2066
|
+
const base = `/api/sessions/${encodeURIComponent(currentSessionId)}/artifacts/${encodeURIComponent(artifactId)}/raw`;
|
|
2067
|
+
return download ? `${base}?download=1` : base;
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2064
2070
|
function renderArtifactShell() {
|
|
2065
2071
|
renderArtifactBadge();
|
|
2066
2072
|
renderArtifactList();
|
|
@@ -2075,23 +2081,40 @@ function renderArtifactContent(content) {
|
|
|
2075
2081
|
artifactViewerTitleEl.textContent = content.title || item?.title || t("artifactViewerEmptyTitle");
|
|
2076
2082
|
artifactViewerMetaEl.textContent = [content.path || item?.path, item ? artifactLabel(item) : ""].filter(Boolean).join(" · ");
|
|
2077
2083
|
artifactViewerKindEl.textContent = content.kind || item?.kind || "";
|
|
2084
|
+
const streamedUrl = content.url || (content.id ? artifactRawUrl(content.id) : "");
|
|
2085
|
+
const downloadUrl = content.downloadUrl || (content.id ? artifactRawUrl(content.id, { download: true }) : "");
|
|
2086
|
+
const renderUrl = streamedUrl || content.dataUrl;
|
|
2078
2087
|
|
|
2079
|
-
if (
|
|
2088
|
+
if (renderUrl && (content.kind === "pdf" || content.mime === "application/pdf")) {
|
|
2080
2089
|
artifactViewerBodyEl.innerHTML = `
|
|
2081
|
-
<iframe class="artifact-pdf-frame" src="${
|
|
2090
|
+
<iframe class="artifact-pdf-frame" src="${renderUrl}" title="${escapeHtml(content.title || "Artifact PDF")}"></iframe>
|
|
2082
2091
|
`;
|
|
2083
2092
|
return;
|
|
2084
2093
|
}
|
|
2085
2094
|
|
|
2086
|
-
if (content.
|
|
2095
|
+
if (renderUrl && (content.kind === "image" || String(content.mime || "").startsWith("image/"))) {
|
|
2087
2096
|
artifactViewerBodyEl.innerHTML = `
|
|
2088
2097
|
<figure class="artifact-image-frame">
|
|
2089
|
-
<img class="artifact-preview-image" src="${
|
|
2098
|
+
<img class="artifact-preview-image" src="${renderUrl}" alt="${escapeHtml(content.title || "Artifact image")}" />
|
|
2090
2099
|
</figure>
|
|
2091
2100
|
`;
|
|
2092
2101
|
return;
|
|
2093
2102
|
}
|
|
2094
2103
|
|
|
2104
|
+
if ((content.tooLargeForInline || content.binary) && streamedUrl) {
|
|
2105
|
+
artifactViewerBodyEl.innerHTML = `
|
|
2106
|
+
<div class="artifact-large-file">
|
|
2107
|
+
<strong>${escapeHtml(content.preview || "Artifact is available as a streamed file.")}</strong>
|
|
2108
|
+
<p>${escapeHtml(content.path || item?.path || content.title || "Generated artifact")}</p>
|
|
2109
|
+
<div class="artifact-file-actions">
|
|
2110
|
+
<a class="secondary-button" href="${streamedUrl}" target="_blank" rel="noreferrer">Open preview</a>
|
|
2111
|
+
<a class="secondary-button" href="${downloadUrl}" download="${escapeHtml(artifactDownloadName(item, content))}">Download</a>
|
|
2112
|
+
</div>
|
|
2113
|
+
</div>
|
|
2114
|
+
`;
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2095
2118
|
const text = typeof content.text === "string" ? content.text : "";
|
|
2096
2119
|
if (content.kind === "markdown" || /markdown/i.test(content.mime || "")) {
|
|
2097
2120
|
artifactViewerBodyEl.innerHTML = `<div class="artifact-markdown markdown-body">${renderMarkdown(text)}</div>`;
|
|
@@ -2110,15 +2133,19 @@ function artifactDownloadName(item, content) {
|
|
|
2110
2133
|
? ".pdf"
|
|
2111
2134
|
: content.mime?.startsWith("image/png")
|
|
2112
2135
|
? ".png"
|
|
2113
|
-
: content.mime?.startsWith("image/
|
|
2114
|
-
? ".
|
|
2115
|
-
: content.
|
|
2116
|
-
? ".
|
|
2117
|
-
: content.
|
|
2118
|
-
? ".
|
|
2119
|
-
: content.kind === "
|
|
2120
|
-
? ".
|
|
2121
|
-
:
|
|
2136
|
+
: content.mime?.startsWith("image/jpeg")
|
|
2137
|
+
? ".jpg"
|
|
2138
|
+
: content.mime?.startsWith("image/webp")
|
|
2139
|
+
? ".webp"
|
|
2140
|
+
: content.mime?.startsWith("image/svg")
|
|
2141
|
+
? ".svg"
|
|
2142
|
+
: content.kind === "json"
|
|
2143
|
+
? ".json"
|
|
2144
|
+
: content.kind === "diff"
|
|
2145
|
+
? ".diff"
|
|
2146
|
+
: content.kind === "markdown"
|
|
2147
|
+
? ".md"
|
|
2148
|
+
: ".txt";
|
|
2122
2149
|
const base = sourceName
|
|
2123
2150
|
.split("/")
|
|
2124
2151
|
.filter(Boolean)
|
|
@@ -2153,6 +2180,21 @@ async function downloadArtifact(artifactId) {
|
|
|
2153
2180
|
const data = await response.json().catch(() => ({}));
|
|
2154
2181
|
if (!response.ok) throw new Error(data.error || t("artifactDownloadFailed"));
|
|
2155
2182
|
|
|
2183
|
+
if ((data.downloadUrl || data.url) && !data.dataUrl && typeof data.text !== "string") {
|
|
2184
|
+
const link = document.createElement("a");
|
|
2185
|
+
link.href = data.downloadUrl || artifactRawUrl(artifactId, { download: true }) || data.url;
|
|
2186
|
+
link.download = artifactDownloadName(item, data);
|
|
2187
|
+
document.body.append(link);
|
|
2188
|
+
link.click();
|
|
2189
|
+
link.remove();
|
|
2190
|
+
|
|
2191
|
+
markArtifactRead(artifactId);
|
|
2192
|
+
renderArtifactBadge();
|
|
2193
|
+
renderArtifactList();
|
|
2194
|
+
artifactStatusEl.textContent = t("artifactDownloadReady");
|
|
2195
|
+
return;
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2156
2198
|
const blob = data.dataUrl
|
|
2157
2199
|
? blobFromDataUrl(data.dataUrl)
|
|
2158
2200
|
: new Blob([typeof data.text === "string" ? data.text : JSON.stringify(data, null, 2)], {
|
package/public/styles.css
CHANGED
|
@@ -1602,6 +1602,29 @@ button.danger {
|
|
|
1602
1602
|
background: white;
|
|
1603
1603
|
}
|
|
1604
1604
|
|
|
1605
|
+
.artifact-large-file {
|
|
1606
|
+
display: grid;
|
|
1607
|
+
gap: 10px;
|
|
1608
|
+
align-content: start;
|
|
1609
|
+
margin: 0;
|
|
1610
|
+
padding: 18px;
|
|
1611
|
+
border: 1px solid var(--line);
|
|
1612
|
+
border-radius: 16px;
|
|
1613
|
+
background: rgba(255, 255, 255, 0.72);
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
.artifact-large-file p {
|
|
1617
|
+
margin: 0;
|
|
1618
|
+
color: var(--muted);
|
|
1619
|
+
overflow-wrap: anywhere;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
.artifact-file-actions {
|
|
1623
|
+
display: flex;
|
|
1624
|
+
flex-wrap: wrap;
|
|
1625
|
+
gap: 8px;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1605
1628
|
.artifact-editor {
|
|
1606
1629
|
min-height: 100%;
|
|
1607
1630
|
height: 100%;
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildArtifacts,
|
|
6
|
+
normalizeCanvasPayload,
|
|
7
|
+
persistCanvasPayloadFile,
|
|
8
|
+
readArtifactContent,
|
|
9
|
+
resolveArtifactFile,
|
|
10
|
+
} from "../src/artifact-tunnel.js";
|
|
5
11
|
import { SessionStore } from "../src/session-store.js";
|
|
6
12
|
|
|
7
13
|
async function main() {
|
|
@@ -54,6 +60,81 @@ async function main() {
|
|
|
54
60
|
if (!content.ok) throw new Error(content.error || "persisted artifact could not be read");
|
|
55
61
|
if (!String(content.text || "").includes("Durable report")) throw new Error("persisted artifact content mismatch");
|
|
56
62
|
|
|
63
|
+
const largeImagePath = path.join(workspace, "large-preview.png");
|
|
64
|
+
const pngHeader = Buffer.from("89504e470d0a1a0a", "hex");
|
|
65
|
+
await fs.writeFile(largeImagePath, Buffer.concat([pngHeader, Buffer.alloc(4_200_000)]));
|
|
66
|
+
const largeNormalized = normalizeCanvasPayload(
|
|
67
|
+
{
|
|
68
|
+
title: "Large preview image",
|
|
69
|
+
kind: "image",
|
|
70
|
+
path: "large-preview.png",
|
|
71
|
+
selected: true,
|
|
72
|
+
},
|
|
73
|
+
config
|
|
74
|
+
);
|
|
75
|
+
if (!largeNormalized.ok) throw new Error(largeNormalized.reason || "large image canvas payload normalization failed");
|
|
76
|
+
const largePersisted = await persistCanvasPayloadFile(largeNormalized.payload, { config, store });
|
|
77
|
+
if (!largePersisted.ok) throw new Error(largePersisted.reason || "large image canvas artifact persistence failed");
|
|
78
|
+
if (!largePersisted.payload.artifactPersisted) throw new Error("large image was not persisted into session artifacts");
|
|
79
|
+
|
|
80
|
+
const largeEvents = [
|
|
81
|
+
{
|
|
82
|
+
timestamp: new Date().toISOString(),
|
|
83
|
+
type: "canvas.item",
|
|
84
|
+
data: {
|
|
85
|
+
...largePersisted.payload,
|
|
86
|
+
commandCwd: workspace,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
const { items: largeItems } = buildArtifacts({ sessionId: store.sessionId, events: largeEvents, store });
|
|
91
|
+
const largeContent = await readArtifactContent(largeItems[0], { store, config });
|
|
92
|
+
if (!largeContent.ok) throw new Error(largeContent.error || "large artifact metadata could not be read");
|
|
93
|
+
if (largeContent.dataUrl) throw new Error("large artifact should not be inlined as a data URL");
|
|
94
|
+
if (!largeContent.url || !largeContent.downloadUrl || !largeContent.tooLargeForInline) {
|
|
95
|
+
throw new Error("large artifact did not expose streamed preview URLs");
|
|
96
|
+
}
|
|
97
|
+
const largeFile = await resolveArtifactFile(largeItems[0], { store, config });
|
|
98
|
+
if (!largeFile.ok || largeFile.size <= 4_000_000 || largeFile.mime !== "image/png") {
|
|
99
|
+
throw new Error("large artifact file resolver returned invalid metadata");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const largePdfPath = path.join(workspace, "compiled-paper.pdf");
|
|
103
|
+
await fs.writeFile(largePdfPath, Buffer.concat([Buffer.from("%PDF-1.7\n"), Buffer.alloc(4_200_000)]));
|
|
104
|
+
const pdfNormalized = normalizeCanvasPayload(
|
|
105
|
+
{
|
|
106
|
+
title: "Compiled paper",
|
|
107
|
+
kind: "pdf",
|
|
108
|
+
path: "compiled-paper.pdf",
|
|
109
|
+
selected: true,
|
|
110
|
+
},
|
|
111
|
+
config
|
|
112
|
+
);
|
|
113
|
+
if (!pdfNormalized.ok) throw new Error(pdfNormalized.reason || "large PDF canvas payload normalization failed");
|
|
114
|
+
const pdfPersisted = await persistCanvasPayloadFile(pdfNormalized.payload, { config, store });
|
|
115
|
+
if (!pdfPersisted.ok) throw new Error(pdfPersisted.reason || "large PDF canvas artifact persistence failed");
|
|
116
|
+
const { items: pdfItems } = buildArtifacts({
|
|
117
|
+
sessionId: store.sessionId,
|
|
118
|
+
events: [
|
|
119
|
+
{
|
|
120
|
+
timestamp: new Date().toISOString(),
|
|
121
|
+
type: "canvas.item",
|
|
122
|
+
data: {
|
|
123
|
+
...pdfPersisted.payload,
|
|
124
|
+
commandCwd: workspace,
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
store,
|
|
129
|
+
});
|
|
130
|
+
const pdfContent = await readArtifactContent(pdfItems[0], { store, config });
|
|
131
|
+
if (!pdfContent.ok || pdfContent.kind !== "pdf" || pdfContent.mime !== "application/pdf") {
|
|
132
|
+
throw new Error(`large PDF artifact did not expose PDF metadata: ${JSON.stringify(pdfContent)}`);
|
|
133
|
+
}
|
|
134
|
+
if (!pdfContent.tooLargeForInline || !pdfContent.url || !pdfContent.downloadUrl || pdfContent.dataUrl) {
|
|
135
|
+
throw new Error("large PDF artifact should stream through preview/download URLs instead of inline data");
|
|
136
|
+
}
|
|
137
|
+
|
|
57
138
|
const missing = await persistCanvasPayloadFile({ ...normalized.payload, path: "missing.png" }, { config, store });
|
|
58
139
|
if (missing.ok) throw new Error("missing canvas path should fail persistence");
|
|
59
140
|
|
|
@@ -26,6 +26,7 @@ async function runCli(args, envOverrides = {}) {
|
|
|
26
26
|
...process.env,
|
|
27
27
|
AGINTIFLOW_RUNTIME_DIR: "",
|
|
28
28
|
AGINTIFLOW_HOME: agintiflowHome,
|
|
29
|
+
AGINTIFLOW_NO_WEB_AUTO_START: "1",
|
|
29
30
|
...envOverrides,
|
|
30
31
|
},
|
|
31
32
|
});
|
|
@@ -41,6 +42,7 @@ async function runCliIn(cwd, args, envOverrides = {}) {
|
|
|
41
42
|
...process.env,
|
|
42
43
|
AGINTIFLOW_RUNTIME_DIR: "",
|
|
43
44
|
AGINTIFLOW_HOME: agintiflowHome,
|
|
45
|
+
AGINTIFLOW_NO_WEB_AUTO_START: "1",
|
|
44
46
|
...envOverrides,
|
|
45
47
|
},
|
|
46
48
|
});
|
|
@@ -78,6 +78,7 @@ function runCli(args, inputText) {
|
|
|
78
78
|
...process.env,
|
|
79
79
|
AGINTIFLOW_RUNTIME_DIR: "",
|
|
80
80
|
AGINTIFLOW_HOME: agintiflowHome,
|
|
81
|
+
AGINTIFLOW_NO_WEB_AUTO_START: "1",
|
|
81
82
|
AGINTIFLOW_PREVIEW_TTL_MS: "1000",
|
|
82
83
|
AGINTI_LANGUAGE: "en",
|
|
83
84
|
},
|
|
@@ -146,6 +147,7 @@ async function runTmuxInterruptSmoke({ key, expected }) {
|
|
|
146
147
|
"env",
|
|
147
148
|
`AGINTIFLOW_HOME=${shellQuote(agintiflowHome)}`,
|
|
148
149
|
"AGINTIFLOW_RUNTIME_DIR=",
|
|
150
|
+
"AGINTIFLOW_NO_WEB_AUTO_START=1",
|
|
149
151
|
"AGINTI_LANGUAGE=en",
|
|
150
152
|
"AGINTI_INTERRUPT_FORCE_EXIT_MS=2500",
|
|
151
153
|
shellQuote(process.execPath),
|
package/scripts/smoke-web-api.js
CHANGED
|
@@ -350,6 +350,37 @@ try {
|
|
|
350
350
|
throw new Error("artifact content endpoint did not return renderable content");
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
const largeSessionId = "large-artifact-smoke";
|
|
354
|
+
const largeStore = new SessionStore(paths.globalSessionsDir, largeSessionId, sessionStoreOptions(runtimeDir, largeSessionId));
|
|
355
|
+
await largeStore.ensure();
|
|
356
|
+
const largeCanvasDir = path.join(largeStore.artifactsDir, "canvas");
|
|
357
|
+
await fs.mkdir(largeCanvasDir, { recursive: true });
|
|
358
|
+
const largeImagePath = path.join(largeCanvasDir, "large-image.png");
|
|
359
|
+
await fs.writeFile(largeImagePath, Buffer.concat([Buffer.from("89504e470d0a1a0a", "hex"), Buffer.alloc(4_200_000)]));
|
|
360
|
+
await largeStore.appendEvent("canvas.item", {
|
|
361
|
+
artifactId: "large-image",
|
|
362
|
+
title: "Large streamed image",
|
|
363
|
+
kind: "image",
|
|
364
|
+
path: "large-image.png",
|
|
365
|
+
sessionFilePath: largeImagePath,
|
|
366
|
+
selected: true,
|
|
367
|
+
});
|
|
368
|
+
const largeArtifacts = await fetchJson(`/api/sessions/${encodeURIComponent(largeSessionId)}/artifacts`);
|
|
369
|
+
if (!largeArtifacts.items?.some((item) => item.id === "large-image")) {
|
|
370
|
+
throw new Error("large artifact endpoint did not list session artifact");
|
|
371
|
+
}
|
|
372
|
+
const largeContent = await fetchJson(`/api/sessions/${encodeURIComponent(largeSessionId)}/artifacts/large-image`);
|
|
373
|
+
if (!largeContent.tooLargeForInline || !largeContent.url || largeContent.dataUrl) {
|
|
374
|
+
throw new Error("large artifact metadata did not switch to streamed preview");
|
|
375
|
+
}
|
|
376
|
+
const rawResponse = await fetch(`${baseUrl}/api/sessions/${encodeURIComponent(largeSessionId)}/artifacts/large-image/raw`);
|
|
377
|
+
if (!rawResponse.ok) throw new Error(`large artifact raw endpoint failed: ${rawResponse.status}`);
|
|
378
|
+
if (!/^image\/png\b/i.test(rawResponse.headers.get("content-type") || "")) {
|
|
379
|
+
throw new Error("large artifact raw endpoint did not preserve image content type");
|
|
380
|
+
}
|
|
381
|
+
const rawBytes = await rawResponse.arrayBuffer();
|
|
382
|
+
if (rawBytes.byteLength <= 4_000_000) throw new Error("large artifact raw endpoint returned truncated content");
|
|
383
|
+
|
|
353
384
|
const deleted = await fetchJson(`/api/sessions/${encodeURIComponent(runStart.sessionId)}`, {
|
|
354
385
|
method: "DELETE",
|
|
355
386
|
});
|
|
@@ -384,6 +415,7 @@ try {
|
|
|
384
415
|
"/api/workspace/changes",
|
|
385
416
|
"/api/sessions/:id/artifacts",
|
|
386
417
|
"/api/sessions/:id/artifacts/:artifactId",
|
|
418
|
+
"/api/sessions/:id/artifacts/:artifactId/raw",
|
|
387
419
|
"POST /api/sessions/:id/artifacts/select",
|
|
388
420
|
],
|
|
389
421
|
provider: run.provider,
|
|
@@ -7,13 +7,18 @@ import { ensureAgintiWebApp } from "../src/web-autostart.js";
|
|
|
7
7
|
|
|
8
8
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
9
|
const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-web-autostart-"));
|
|
10
|
+
const homeDir = path.join(runtimeDir, "stable-web-home");
|
|
11
|
+
const inheritedHome = path.join(runtimeDir, "leaked-cli-home");
|
|
10
12
|
const preferredPort = 44500 + Math.floor(Math.random() * 1000);
|
|
11
13
|
let childPid = 0;
|
|
14
|
+
const originalHome = process.env.AGINTIFLOW_HOME;
|
|
12
15
|
|
|
13
16
|
try {
|
|
17
|
+
process.env.AGINTIFLOW_HOME = inheritedHome;
|
|
14
18
|
const first = await ensureAgintiWebApp({
|
|
15
19
|
packageDir: repoRoot,
|
|
16
20
|
cwd: runtimeDir,
|
|
21
|
+
home: homeDir,
|
|
17
22
|
preferredPort,
|
|
18
23
|
host: "127.0.0.1",
|
|
19
24
|
});
|
|
@@ -25,17 +30,35 @@ try {
|
|
|
25
30
|
if (!health.ok || Number(health.port) !== preferredPort) {
|
|
26
31
|
throw new Error(`auto-started web health was invalid: ${JSON.stringify(health)}`);
|
|
27
32
|
}
|
|
33
|
+
if (path.resolve(health.agintiflowHome) !== path.resolve(homeDir) || path.resolve(health.runtimeDir) !== path.resolve(runtimeDir)) {
|
|
34
|
+
throw new Error(`auto-started webapp inherited the wrong context: ${JSON.stringify(health)}`);
|
|
35
|
+
}
|
|
28
36
|
const second = await ensureAgintiWebApp({
|
|
29
37
|
packageDir: repoRoot,
|
|
30
38
|
cwd: runtimeDir,
|
|
39
|
+
home: homeDir,
|
|
31
40
|
preferredPort,
|
|
32
41
|
host: "127.0.0.1",
|
|
33
42
|
});
|
|
34
43
|
if (!second.ok || !second.reused || second.url !== first.url) {
|
|
35
44
|
throw new Error(`expected second auto-start call to reuse existing webapp, got ${JSON.stringify(second)}`);
|
|
36
45
|
}
|
|
46
|
+
const restarted = await ensureAgintiWebApp({
|
|
47
|
+
packageDir: repoRoot,
|
|
48
|
+
cwd: runtimeDir,
|
|
49
|
+
home: homeDir,
|
|
50
|
+
preferredPort,
|
|
51
|
+
host: "127.0.0.1",
|
|
52
|
+
restart: true,
|
|
53
|
+
});
|
|
54
|
+
if (!restarted.ok || !restarted.restarted || restarted.url !== first.url || Number(restarted.pid) === childPid) {
|
|
55
|
+
throw new Error(`expected restart on same URL with a new pid, got ${JSON.stringify(restarted)}`);
|
|
56
|
+
}
|
|
57
|
+
childPid = Number(restarted.pid) || childPid;
|
|
37
58
|
console.log(`web auto-start smoke passed: ${first.url}`);
|
|
38
59
|
} finally {
|
|
60
|
+
if (originalHome === undefined) delete process.env.AGINTIFLOW_HOME;
|
|
61
|
+
else process.env.AGINTIFLOW_HOME = originalHome;
|
|
39
62
|
if (childPid) {
|
|
40
63
|
try {
|
|
41
64
|
process.kill(childPid, "SIGTERM");
|
|
@@ -47,7 +47,8 @@ async function runCase({ port, env = {}, expectHeader, label }) {
|
|
|
47
47
|
env: {
|
|
48
48
|
...process.env,
|
|
49
49
|
AGINTIFLOW_NO_ANIMATION: "1",
|
|
50
|
-
AGINTIFLOW_HOME: path.join(runtimeDir, `.
|
|
50
|
+
AGINTIFLOW_HOME: path.join(runtimeDir, `.ignored-cli-home-${label}`),
|
|
51
|
+
AGINTIFLOW_WEB_HOME: path.join(runtimeDir, `.agintiflow-web-home-${label}`),
|
|
51
52
|
...env,
|
|
52
53
|
},
|
|
53
54
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -64,10 +65,15 @@ async function runCase({ port, env = {}, expectHeader, label }) {
|
|
|
64
65
|
await waitFor(() => output.stdout.includes(expectHeader), child, `${label} launch header`, output);
|
|
65
66
|
child.stdin.write(`/webapp ${port}\n`);
|
|
66
67
|
await waitFor(() => output.stdout.includes(`webapp=http://127.0.0.1:${port}`), child, `${label} /webapp command`, output);
|
|
68
|
+
child.stdin.write(`/webapp restart ${port}\n`);
|
|
69
|
+
await waitFor(() => output.stdout.includes(`webapp=http://127.0.0.1:${port} restarted`), child, `${label} /webapp restart command`, output);
|
|
67
70
|
const health = await fetch(`http://127.0.0.1:${port}/health`).then((response) => response.json());
|
|
68
71
|
if (!health.ok || health.app !== "agintiflow" || Number(health.port) !== port) {
|
|
69
72
|
throw new Error(`invalid /webapp health response for ${label}: ${JSON.stringify(health)}`);
|
|
70
73
|
}
|
|
74
|
+
if (path.resolve(health.agintiflowHome) !== path.resolve(path.join(runtimeDir, `.agintiflow-web-home-${label}`))) {
|
|
75
|
+
throw new Error(`webapp command inherited the wrong home for ${label}: ${JSON.stringify(health)}`);
|
|
76
|
+
}
|
|
71
77
|
} finally {
|
|
72
78
|
child.kill("SIGTERM");
|
|
73
79
|
await killPort(port);
|
package/src/artifact-tunnel.js
CHANGED
|
@@ -60,6 +60,16 @@ function mimeForPath(filePath) {
|
|
|
60
60
|
return IMAGE_MIME_BY_EXT.get(ext) || BINARY_RENDER_MIME_BY_EXT.get(ext) || "text/plain; charset=utf-8";
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function isRenderableBinaryMime(mime) {
|
|
64
|
+
return String(mime || "").startsWith("image/") || mime === "application/pdf";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function artifactRawUrl(item, download = false) {
|
|
68
|
+
if (!item?.sessionId || !item?.id) return "";
|
|
69
|
+
const base = `/api/sessions/${encodeURIComponent(item.sessionId)}/artifacts/${encodeURIComponent(item.id)}/raw`;
|
|
70
|
+
return download ? `${base}?download=1` : base;
|
|
71
|
+
}
|
|
72
|
+
|
|
63
73
|
function safeCanvasFilename(artifactId, filePath) {
|
|
64
74
|
const rawBase = path.basename(String(filePath || "artifact"));
|
|
65
75
|
const safeBase = rawBase
|
|
@@ -285,24 +295,13 @@ export function findArtifact(items, artifactId) {
|
|
|
285
295
|
return items.find((item) => item.id === artifactId) || null;
|
|
286
296
|
}
|
|
287
297
|
|
|
288
|
-
export async function
|
|
298
|
+
export async function resolveArtifactFile(item, { store, config } = {}) {
|
|
289
299
|
if (!item) {
|
|
290
300
|
return { ok: false, error: "Artifact not found." };
|
|
291
301
|
}
|
|
292
302
|
|
|
293
|
-
if (item.ref?.type === "inline") {
|
|
294
|
-
return {
|
|
295
|
-
ok: true,
|
|
296
|
-
id: item.id,
|
|
297
|
-
kind: item.kind,
|
|
298
|
-
title: item.title,
|
|
299
|
-
path: item.path || "",
|
|
300
|
-
mime: item.mime || "text/plain; charset=utf-8",
|
|
301
|
-
text: redactSensitiveText(String(item.ref.text || "")),
|
|
302
|
-
};
|
|
303
|
-
}
|
|
304
|
-
|
|
305
303
|
if (item.ref?.type === "session-file") {
|
|
304
|
+
if (!store?.sessionDir) return { ok: false, error: "Artifact session store is unavailable." };
|
|
306
305
|
const absolutePath = path.resolve(item.ref.path);
|
|
307
306
|
if (!isInside(store.sessionDir, absolutePath)) {
|
|
308
307
|
return { ok: false, error: "Artifact path is outside this session." };
|
|
@@ -311,32 +310,14 @@ export async function readArtifactContent(item, { store, config }) {
|
|
|
311
310
|
const stat = await fs.stat(absolutePath).catch(() => null);
|
|
312
311
|
if (!stat?.isFile()) return { ok: false, error: "Artifact file is missing." };
|
|
313
312
|
const mime = mimeForPath(absolutePath);
|
|
314
|
-
const isBinaryRenderable = mime.startsWith("image/") || mime === "application/pdf";
|
|
315
|
-
const maxBytes = isBinaryRenderable ? MAX_ARTIFACT_IMAGE_BYTES : MAX_ARTIFACT_TEXT_BYTES;
|
|
316
|
-
if (stat.size > maxBytes) return { ok: false, error: "Artifact is too large to preview safely." };
|
|
317
|
-
|
|
318
|
-
const buffer = await fs.readFile(absolutePath);
|
|
319
|
-
if (isBinaryRenderable) {
|
|
320
|
-
return {
|
|
321
|
-
ok: true,
|
|
322
|
-
id: item.id,
|
|
323
|
-
kind: mime === "application/pdf" ? "pdf" : "image",
|
|
324
|
-
title: item.title,
|
|
325
|
-
path: item.path || "",
|
|
326
|
-
mime,
|
|
327
|
-
dataUrl: `data:${mime};base64,${buffer.toString("base64")}`,
|
|
328
|
-
};
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
if (buffer.includes(0)) return { ok: false, error: "Binary artifact cannot be rendered as text." };
|
|
332
313
|
return {
|
|
333
314
|
ok: true,
|
|
334
|
-
|
|
335
|
-
|
|
315
|
+
absolutePath,
|
|
316
|
+
size: stat.size,
|
|
317
|
+
mime,
|
|
318
|
+
kind: mime === "application/pdf" ? "pdf" : mime.startsWith("image/") ? "image" : item.kind,
|
|
336
319
|
title: item.title,
|
|
337
320
|
path: item.path || "",
|
|
338
|
-
mime,
|
|
339
|
-
text: redactSensitiveText(buffer.toString("utf8")),
|
|
340
321
|
};
|
|
341
322
|
}
|
|
342
323
|
|
|
@@ -350,33 +331,87 @@ export async function readArtifactContent(item, { store, config }) {
|
|
|
350
331
|
const target = resolveWorkspacePath(itemConfig, item.ref.path);
|
|
351
332
|
const stat = await fs.stat(target.absolutePath).catch(() => null);
|
|
352
333
|
if (!stat?.isFile()) return { ok: false, error: "Workspace file is missing." };
|
|
353
|
-
|
|
354
334
|
const mime = mimeForPath(target.absolutePath);
|
|
355
|
-
|
|
335
|
+
return {
|
|
336
|
+
ok: true,
|
|
337
|
+
absolutePath: target.absolutePath,
|
|
338
|
+
size: stat.size,
|
|
339
|
+
mime,
|
|
340
|
+
kind: mime === "application/pdf" ? "pdf" : mime.startsWith("image/") ? "image" : item.kind,
|
|
341
|
+
title: item.title,
|
|
342
|
+
path: item.path || "",
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { ok: false, error: "Artifact has no readable file." };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export async function readArtifactContent(item, { store, config }) {
|
|
350
|
+
if (!item) {
|
|
351
|
+
return { ok: false, error: "Artifact not found." };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (item.ref?.type === "inline") {
|
|
355
|
+
return {
|
|
356
|
+
ok: true,
|
|
357
|
+
id: item.id,
|
|
358
|
+
kind: item.kind,
|
|
359
|
+
title: item.title,
|
|
360
|
+
path: item.path || "",
|
|
361
|
+
mime: item.mime || "text/plain; charset=utf-8",
|
|
362
|
+
text: redactSensitiveText(String(item.ref.text || "")),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (item.ref?.type === "session-file" || item.ref?.type === "workspace-file") {
|
|
367
|
+
const resolved = await resolveArtifactFile(item, { store, config });
|
|
368
|
+
if (!resolved.ok) return resolved;
|
|
369
|
+
|
|
370
|
+
const isBinaryRenderable = isRenderableBinaryMime(resolved.mime);
|
|
356
371
|
const maxBytes = isBinaryRenderable ? MAX_ARTIFACT_IMAGE_BYTES : MAX_ARTIFACT_TEXT_BYTES;
|
|
357
|
-
|
|
372
|
+
const url = artifactRawUrl(item);
|
|
373
|
+
const downloadUrl = artifactRawUrl(item, true);
|
|
374
|
+
const base = {
|
|
375
|
+
ok: true,
|
|
376
|
+
id: item.id,
|
|
377
|
+
kind: resolved.kind,
|
|
378
|
+
title: item.title,
|
|
379
|
+
path: item.path || "",
|
|
380
|
+
mime: resolved.mime,
|
|
381
|
+
size: resolved.size,
|
|
382
|
+
url,
|
|
383
|
+
downloadUrl,
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
if (resolved.size > maxBytes) {
|
|
387
|
+
return {
|
|
388
|
+
...base,
|
|
389
|
+
tooLargeForInline: true,
|
|
390
|
+
preview: isBinaryRenderable
|
|
391
|
+
? "Large renderable artifact is available through the streamed artifact endpoint."
|
|
392
|
+
: "Large artifact is available through the streamed artifact endpoint.",
|
|
393
|
+
};
|
|
394
|
+
}
|
|
358
395
|
|
|
359
|
-
const buffer = await fs.readFile(
|
|
396
|
+
const buffer = await fs.readFile(resolved.absolutePath);
|
|
360
397
|
if (isBinaryRenderable) {
|
|
361
398
|
return {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
399
|
+
...base,
|
|
400
|
+
dataUrl: `data:${resolved.mime};base64,${buffer.toString("base64")}`,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (buffer.includes(0)) {
|
|
405
|
+
return {
|
|
406
|
+
...base,
|
|
407
|
+
binary: true,
|
|
408
|
+
preview: "Binary artifact is available through the streamed artifact endpoint.",
|
|
369
409
|
};
|
|
370
410
|
}
|
|
371
411
|
|
|
372
|
-
if (buffer.includes(0)) return { ok: false, error: "Binary workspace file cannot be rendered as text." };
|
|
373
412
|
return {
|
|
374
|
-
|
|
375
|
-
id: item.id,
|
|
413
|
+
...base,
|
|
376
414
|
kind: item.kind,
|
|
377
|
-
title: item.title,
|
|
378
|
-
path: item.path || "",
|
|
379
|
-
mime,
|
|
380
415
|
text: redactSensitiveText(buffer.toString("utf8")),
|
|
381
416
|
};
|
|
382
417
|
}
|
package/src/cli.js
CHANGED
|
@@ -95,11 +95,12 @@ function printUnknownCliOptions(options = []) {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
async function maybeEnsureDefaultWebApp(args = {}, { commandCwd = process.cwd() } = {}) {
|
|
98
|
-
if (args.web) return { ok: false, url: "" };
|
|
98
|
+
if (args.web || args.webapp) return { ok: false, url: "" };
|
|
99
99
|
try {
|
|
100
100
|
return await ensureAgintiWebApp({
|
|
101
101
|
packageDir,
|
|
102
102
|
cwd: args.commandCwd || commandCwd || process.cwd(),
|
|
103
|
+
home: args.webHome || "",
|
|
103
104
|
host: args.host || process.env.AGINTI_WEB_HOST || "127.0.0.1",
|
|
104
105
|
preferredPort: args.port || process.env.AGINTI_WEB_PORT || 3210,
|
|
105
106
|
language: args.language ? resolveLanguage(args.language) : "",
|
|
@@ -379,6 +380,9 @@ export function parseArgs(argv) {
|
|
|
379
380
|
sandboxStatus: false,
|
|
380
381
|
sandboxPreflight: false,
|
|
381
382
|
web: false,
|
|
383
|
+
webapp: false,
|
|
384
|
+
webAction: "",
|
|
385
|
+
webHome: "",
|
|
382
386
|
interactive: false,
|
|
383
387
|
port: "",
|
|
384
388
|
host: "",
|
|
@@ -399,6 +403,21 @@ export function parseArgs(argv) {
|
|
|
399
403
|
parts.push(...argv.slice(i + 1));
|
|
400
404
|
break;
|
|
401
405
|
}
|
|
406
|
+
if ((arg === "web" || arg === "--web") && String(argv[i + 1] || "").toLowerCase() === "restart") {
|
|
407
|
+
result.webapp = true;
|
|
408
|
+
result.webAction = "restart";
|
|
409
|
+
i += 1;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (arg === "webapp" || arg === "--webapp" || arg === "web-ui" || arg === "--web-ui") {
|
|
413
|
+
result.webapp = true;
|
|
414
|
+
const next = String(argv[i + 1] || "");
|
|
415
|
+
if (next && !next.startsWith("--")) {
|
|
416
|
+
result.webAction = next.toLowerCase();
|
|
417
|
+
i += 1;
|
|
418
|
+
}
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
402
421
|
if (arg === "web" || arg === "--web") {
|
|
403
422
|
result.web = true;
|
|
404
423
|
continue;
|
|
@@ -417,6 +436,11 @@ export function parseArgs(argv) {
|
|
|
417
436
|
i += 1;
|
|
418
437
|
continue;
|
|
419
438
|
}
|
|
439
|
+
if (arg === "--web-home") {
|
|
440
|
+
result.webHome = readOption(argv, i);
|
|
441
|
+
i += 1;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
420
444
|
if (arg === "--language" || arg === "--lang" || arg === "-L") {
|
|
421
445
|
const first = readOption(argv, i);
|
|
422
446
|
const second = argv[i + 2] && !String(argv[i + 2]).startsWith("--") ? argv[i + 2] : "";
|
|
@@ -1880,6 +1904,33 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1880
1904
|
const args = { ...parsedArgs, commandCwd: parsedArgs.commandCwd || commandCwd };
|
|
1881
1905
|
exitOnUnknownOptions(args);
|
|
1882
1906
|
|
|
1907
|
+
if (args.webapp) {
|
|
1908
|
+
const action = String(args.webAction || "start").toLowerCase();
|
|
1909
|
+
if (!["start", "restart", "reuse"].includes(action)) {
|
|
1910
|
+
console.error("Usage: aginti webapp [start|restart] [--port 3210] [--host 127.0.0.1]");
|
|
1911
|
+
process.exit(1);
|
|
1912
|
+
}
|
|
1913
|
+
const result = await ensureAgintiWebApp({
|
|
1914
|
+
packageDir,
|
|
1915
|
+
cwd: args.commandCwd || commandCwd,
|
|
1916
|
+
home: args.webHome || "",
|
|
1917
|
+
host: args.host || process.env.AGINTI_WEB_HOST || "127.0.0.1",
|
|
1918
|
+
preferredPort: args.port || process.env.AGINTI_WEB_PORT || 3210,
|
|
1919
|
+
language: args.language ? resolveLanguage(args.language) : "",
|
|
1920
|
+
restart: action === "restart",
|
|
1921
|
+
respectAutoStartDisable: false,
|
|
1922
|
+
}).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error), url: "" }));
|
|
1923
|
+
if (!result.ok) {
|
|
1924
|
+
console.error(`webapp unavailable: ${result.error || "unknown"}`);
|
|
1925
|
+
process.exit(1);
|
|
1926
|
+
}
|
|
1927
|
+
const state = result.restarted ? "restarted" : result.reused ? "reused" : "started";
|
|
1928
|
+
console.log(`webapp: ${result.url} ${state}`);
|
|
1929
|
+
console.log(`project: ${result.runtimeDir || path.resolve(args.commandCwd || commandCwd)}`);
|
|
1930
|
+
console.log(`home: ${result.agintiflowHome || ""}`);
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1883
1934
|
if (args.web) {
|
|
1884
1935
|
if (args.port) process.env.PORT = String(args.port);
|
|
1885
1936
|
if (args.host) process.env.HOST = String(args.host);
|
package/src/i18n.js
CHANGED
|
@@ -139,7 +139,7 @@ const TRANSLATIONS = {
|
|
|
139
139
|
helpSkills: "List Markdown skills selected for a topic.",
|
|
140
140
|
helpSkillMesh: "Manage strict reviewed skill sharing.",
|
|
141
141
|
helpProfile: "Set task profile, e.g. code, website, latex, maintenance.",
|
|
142
|
-
helpWebapp: "Start or
|
|
142
|
+
helpWebapp: "Start, reuse, or restart the local webapp and print its URL.",
|
|
143
143
|
helpWebSearch: "Enable or disable the web_search tool.",
|
|
144
144
|
helpEnableScs: "Toggle Student-Committee-Supervisor gated execution.",
|
|
145
145
|
helpScouts: "Enable parallel DeepSeek scouts and set scout count.",
|
package/src/interactive-cli.js
CHANGED
|
@@ -881,7 +881,7 @@ function printHelp() {
|
|
|
881
881
|
` ${command("/skills [query]", "List Markdown skills selected for a topic.", "helpSkills")}`,
|
|
882
882
|
` ${command("/skillmesh [status|off|record|share|sync]", "Manage strict reviewed skill sharing.", "helpSkillMesh")}`,
|
|
883
883
|
` ${command("/profile <name>", "Set task profile, e.g. code, website, latex, maintenance.", "helpProfile")}`,
|
|
884
|
-
` ${command("/webapp [port]", "Start or
|
|
884
|
+
` ${command("/webapp [port|restart]", "Start, reuse, or restart the local webapp and print its URL.", "helpWebapp")}`,
|
|
885
885
|
` ${command("/web-search on|off", "Enable or disable the web_search tool.", "helpWebSearch")}`,
|
|
886
886
|
` ${command("/web-research <query>", "Run a sourced web_research turn with persisted evidence.", "helpWebSearch")}`,
|
|
887
887
|
` ${command("/image-read <path> [question]", "Run read_image on a workspace screenshot/image.", "helpWebSearch")}`,
|
|
@@ -3010,20 +3010,24 @@ async function handleCommand(line, state, packageDir) {
|
|
|
3010
3010
|
return true;
|
|
3011
3011
|
}
|
|
3012
3012
|
if (command === "webapp" || command === "web") {
|
|
3013
|
-
const
|
|
3014
|
-
|
|
3013
|
+
const words = value.split(/\s+/).filter(Boolean);
|
|
3014
|
+
const restart = words.some((word) => word.toLowerCase() === "restart");
|
|
3015
|
+
const portValue = words.find((word) => /^\d+$/.test(word));
|
|
3016
|
+
const port = Number(portValue) || Number(process.env.AGINTI_WEB_PORT || process.env.PORT || 3210);
|
|
3017
|
+
printSystemLine(restart ? "webapp=restarting" : "webapp=starting");
|
|
3015
3018
|
const result = await ensureAgintiWebApp({
|
|
3016
3019
|
packageDir,
|
|
3017
3020
|
cwd: state.commandCwd || process.cwd(),
|
|
3018
3021
|
host: process.env.AGINTI_WEB_HOST || process.env.HOST || "127.0.0.1",
|
|
3019
3022
|
preferredPort: port,
|
|
3020
3023
|
language: state.language,
|
|
3024
|
+
restart,
|
|
3021
3025
|
respectAutoStartDisable: false,
|
|
3022
3026
|
}).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error), url: "" }));
|
|
3023
3027
|
if (result.ok) {
|
|
3024
3028
|
state.webAppUrl = result.url;
|
|
3025
3029
|
state.webAppNotice = "";
|
|
3026
|
-
printSystemLine(`webapp=${result.url} ${result.reused ? "reused" : "started"}`);
|
|
3030
|
+
printSystemLine(`webapp=${result.url} ${result.restarted ? "restarted" : result.reused ? "reused" : "started"}`);
|
|
3027
3031
|
} else {
|
|
3028
3032
|
state.webAppUrl = "";
|
|
3029
3033
|
state.webAppNotice = `webapp unavailable - use /webapp to retry; error: ${compactLine(result.error || "unknown", 72)}`;
|
package/src/web-autostart.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
2
|
import http from "node:http";
|
|
3
3
|
import net from "node:net";
|
|
4
|
+
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
|
|
6
7
|
const DEFAULT_HOST = "127.0.0.1";
|
|
@@ -20,7 +21,40 @@ function webUrl(host, port) {
|
|
|
20
21
|
return `http://${host}:${port}`;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
function
|
|
24
|
+
function isInside(root, target) {
|
|
25
|
+
const relative = path.relative(root, target);
|
|
26
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function samePath(left = "", right = "") {
|
|
30
|
+
if (!left || !right) return false;
|
|
31
|
+
return path.resolve(left) === path.resolve(right);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function defaultAgintiflowHome() {
|
|
35
|
+
return path.join(os.homedir(), ".agintiflow");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isLikelyTransientPath(value = "") {
|
|
39
|
+
if (!value) return false;
|
|
40
|
+
const resolved = path.resolve(value);
|
|
41
|
+
const tmp = path.resolve(os.tmpdir());
|
|
42
|
+
return isInside(tmp, resolved) || /agintiflow-(cli-chat|webapp-command|web-autostart|web-port|smoke|test)-/i.test(resolved);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolveWebHome(home = "") {
|
|
46
|
+
const explicit = home || process.env.AGINTIFLOW_WEB_HOME || process.env.AGINTI_WEB_HOME || "";
|
|
47
|
+
if (explicit) return path.resolve(explicit);
|
|
48
|
+
const inherited = process.env.AGINTIFLOW_HOME || "";
|
|
49
|
+
if (inherited && !isLikelyTransientPath(inherited)) return path.resolve(inherited);
|
|
50
|
+
return defaultAgintiflowHome();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveRuntimeDir(cwd = "") {
|
|
54
|
+
return path.resolve(process.env.AGINTIFLOW_WEB_RUNTIME_DIR || cwd || process.cwd());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fetchHealthDetails(host, port, timeoutMs = 450) {
|
|
24
58
|
return new Promise((resolve) => {
|
|
25
59
|
const req = http.get(`${webUrl(host, port)}/health`, { timeout: timeoutMs }, (res) => {
|
|
26
60
|
let body = "";
|
|
@@ -31,20 +65,28 @@ function fetchHealth(host, port, timeoutMs = 450) {
|
|
|
31
65
|
res.on("end", () => {
|
|
32
66
|
try {
|
|
33
67
|
const json = JSON.parse(body || "{}");
|
|
34
|
-
resolve(
|
|
68
|
+
resolve({
|
|
69
|
+
ok: Boolean(res.statusCode === 200 && json.ok && (json.app === "agintiflow" || Number(json.port) === port)),
|
|
70
|
+
statusCode: res.statusCode,
|
|
71
|
+
...json,
|
|
72
|
+
});
|
|
35
73
|
} catch {
|
|
36
|
-
resolve(false);
|
|
74
|
+
resolve({ ok: false });
|
|
37
75
|
}
|
|
38
76
|
});
|
|
39
77
|
});
|
|
40
78
|
req.on("timeout", () => {
|
|
41
79
|
req.destroy();
|
|
42
|
-
resolve(false);
|
|
80
|
+
resolve({ ok: false });
|
|
43
81
|
});
|
|
44
|
-
req.on("error", () => resolve(false));
|
|
82
|
+
req.on("error", () => resolve({ ok: false }));
|
|
45
83
|
});
|
|
46
84
|
}
|
|
47
85
|
|
|
86
|
+
async function fetchHealth(host, port, timeoutMs = 450) {
|
|
87
|
+
return (await fetchHealthDetails(host, port, timeoutMs)).ok;
|
|
88
|
+
}
|
|
89
|
+
|
|
48
90
|
function canListen(host, port) {
|
|
49
91
|
return new Promise((resolve) => {
|
|
50
92
|
const server = net.createServer();
|
|
@@ -64,14 +106,104 @@ async function waitForHealth(host, port, timeoutMs = 7000) {
|
|
|
64
106
|
return false;
|
|
65
107
|
}
|
|
66
108
|
|
|
67
|
-
|
|
109
|
+
function compatibleHealth(health = {}, { cwd = "", home = "", packageDir = "" } = {}) {
|
|
110
|
+
if (!health.ok || health.app !== "agintiflow") return false;
|
|
111
|
+
if (!health.runtimeDir || !health.agintiflowHome) return false;
|
|
112
|
+
if (!samePath(health.runtimeDir, cwd)) return false;
|
|
113
|
+
if (!samePath(health.agintiflowHome, home)) return false;
|
|
114
|
+
if (health.packageDir && packageDir && !samePath(health.packageDir, packageDir)) return false;
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function listenerPids(port) {
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
execFile("lsof", [`-tiTCP:${port}`, "-sTCP:LISTEN"], { encoding: "utf8" }, (error, stdout) => {
|
|
121
|
+
if (error) {
|
|
122
|
+
resolve([]);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
resolve(
|
|
126
|
+
String(stdout || "")
|
|
127
|
+
.split(/\s+/)
|
|
128
|
+
.map((value) => Number(value))
|
|
129
|
+
.filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid)
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function waitForPortRelease(host, port, timeoutMs = 5000) {
|
|
136
|
+
const deadline = Date.now() + timeoutMs;
|
|
137
|
+
while (Date.now() < deadline) {
|
|
138
|
+
if (!(await fetchHealth(host, port, 220)) && (await canListen(host, port))) return true;
|
|
139
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function stopWebAppOnPort({ host, port, health = {} } = {}) {
|
|
145
|
+
const pids = new Set();
|
|
146
|
+
if (Number.isInteger(Number(health.pid)) && Number(health.pid) > 0) pids.add(Number(health.pid));
|
|
147
|
+
for (const pid of await listenerPids(port)) pids.add(pid);
|
|
148
|
+
if (pids.size === 0) return { ok: false, error: `Could not identify AgInTiFlow webapp process on ${host}:${port}.` };
|
|
149
|
+
|
|
150
|
+
for (const pid of pids) {
|
|
151
|
+
try {
|
|
152
|
+
process.kill(pid, "SIGTERM");
|
|
153
|
+
} catch {
|
|
154
|
+
// Already stopped or not owned by this user.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (await waitForPortRelease(host, port, 3500)) return { ok: true, pids: [...pids], forced: false };
|
|
158
|
+
|
|
159
|
+
for (const pid of pids) {
|
|
160
|
+
try {
|
|
161
|
+
process.kill(pid, "SIGKILL");
|
|
162
|
+
} catch {
|
|
163
|
+
// Ignore.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const released = await waitForPortRelease(host, port, 2000);
|
|
167
|
+
return released
|
|
168
|
+
? { ok: true, pids: [...pids], forced: true }
|
|
169
|
+
: { ok: false, pids: [...pids], error: `AgInTiFlow webapp on ${host}:${port} did not stop.` };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function findReusableOrFreeWebPort({
|
|
173
|
+
host = DEFAULT_HOST,
|
|
174
|
+
preferredPort = DEFAULT_PORT,
|
|
175
|
+
attempts = MAX_PORT_ATTEMPTS,
|
|
176
|
+
cwd = process.cwd(),
|
|
177
|
+
home = "",
|
|
178
|
+
packageDir = process.cwd(),
|
|
179
|
+
restart = false,
|
|
180
|
+
} = {}) {
|
|
68
181
|
const startPort = normalizePort(preferredPort);
|
|
69
182
|
const normalizedHost = normalizeHost(host);
|
|
183
|
+
const runtimeDir = resolveRuntimeDir(cwd);
|
|
184
|
+
const homeDir = resolveWebHome(home);
|
|
70
185
|
for (let offset = 0; offset < attempts; offset += 1) {
|
|
71
186
|
const port = startPort + offset;
|
|
72
187
|
if (port >= 65536) break;
|
|
73
|
-
|
|
74
|
-
|
|
188
|
+
const health = await fetchHealthDetails(normalizedHost, port);
|
|
189
|
+
if (health.ok) {
|
|
190
|
+
if (restart && Number(port) === startPort) {
|
|
191
|
+
const stopped = await stopWebAppOnPort({ host: normalizedHost, port, health });
|
|
192
|
+
if (!stopped.ok) return { port, host: normalizedHost, url: "", reused: false, available: false, stopped, error: stopped.error };
|
|
193
|
+
return {
|
|
194
|
+
port,
|
|
195
|
+
host: normalizedHost,
|
|
196
|
+
url: webUrl(normalizedHost, port),
|
|
197
|
+
reused: false,
|
|
198
|
+
available: true,
|
|
199
|
+
restarted: true,
|
|
200
|
+
stopped,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (compatibleHealth(health, { cwd: runtimeDir, home: homeDir, packageDir })) {
|
|
204
|
+
return { port, host: normalizedHost, url: webUrl(normalizedHost, port), reused: true, available: false, health };
|
|
205
|
+
}
|
|
206
|
+
continue;
|
|
75
207
|
}
|
|
76
208
|
if (await canListen(normalizedHost, port)) {
|
|
77
209
|
return { port, host: normalizedHost, url: webUrl(normalizedHost, port), reused: false, available: true };
|
|
@@ -83,32 +215,40 @@ export async function findReusableOrFreeWebPort({ host = DEFAULT_HOST, preferred
|
|
|
83
215
|
export async function ensureAgintiWebApp({
|
|
84
216
|
packageDir = process.cwd(),
|
|
85
217
|
cwd = process.cwd(),
|
|
218
|
+
home = "",
|
|
86
219
|
host = DEFAULT_HOST,
|
|
87
220
|
preferredPort = DEFAULT_PORT,
|
|
88
221
|
language = "",
|
|
222
|
+
restart = false,
|
|
89
223
|
respectAutoStartDisable = true,
|
|
90
224
|
} = {}) {
|
|
91
225
|
if (respectAutoStartDisable && (process.env.AGINTI_NO_WEB_AUTO_START === "1" || process.env.AGINTIFLOW_NO_WEB_AUTO_START === "1")) {
|
|
92
226
|
return { ok: false, disabled: true, url: "" };
|
|
93
227
|
}
|
|
94
228
|
|
|
95
|
-
const
|
|
229
|
+
const runtimeDir = resolveRuntimeDir(cwd);
|
|
230
|
+
const homeDir = resolveWebHome(home);
|
|
231
|
+
const candidate = await findReusableOrFreeWebPort({ host, preferredPort, cwd: runtimeDir, home: homeDir, packageDir, restart });
|
|
96
232
|
if (!candidate.port) {
|
|
97
|
-
return { ok: false, error: `No available AgInTiFlow web port from ${normalizePort(preferredPort)}.`, url: "" };
|
|
233
|
+
return { ok: false, error: candidate.error || `No available AgInTiFlow web port from ${normalizePort(preferredPort)}.`, url: "" };
|
|
234
|
+
}
|
|
235
|
+
if (candidate.error || (!candidate.available && !candidate.reused)) {
|
|
236
|
+
return { ok: false, error: candidate.error || `No reusable or free AgInTiFlow web port from ${normalizePort(preferredPort)}.`, url: "" };
|
|
98
237
|
}
|
|
99
238
|
if (candidate.reused) {
|
|
100
|
-
return { ok: true, reused: true, started: false, ...candidate };
|
|
239
|
+
return { ok: true, reused: true, started: false, runtimeDir, agintiflowHome: homeDir, ...candidate };
|
|
101
240
|
}
|
|
102
241
|
|
|
103
242
|
const child = spawn(process.execPath, [path.join(packageDir, "web.js")], {
|
|
104
|
-
cwd,
|
|
243
|
+
cwd: runtimeDir,
|
|
105
244
|
detached: true,
|
|
106
245
|
stdio: "ignore",
|
|
107
246
|
env: {
|
|
108
247
|
...process.env,
|
|
109
248
|
HOST: candidate.host,
|
|
110
249
|
PORT: String(candidate.port),
|
|
111
|
-
AGINTIFLOW_RUNTIME_DIR:
|
|
250
|
+
AGINTIFLOW_RUNTIME_DIR: runtimeDir,
|
|
251
|
+
AGINTIFLOW_HOME: homeDir,
|
|
112
252
|
AGINTIFLOW_PACKAGE_DIR: packageDir,
|
|
113
253
|
...(language ? { AGINTI_LANGUAGE: language } : {}),
|
|
114
254
|
},
|
|
@@ -117,6 +257,16 @@ export async function ensureAgintiWebApp({
|
|
|
117
257
|
|
|
118
258
|
const healthy = await waitForHealth(candidate.host, candidate.port);
|
|
119
259
|
return healthy
|
|
120
|
-
? {
|
|
260
|
+
? {
|
|
261
|
+
ok: true,
|
|
262
|
+
reused: false,
|
|
263
|
+
started: true,
|
|
264
|
+
restarted: Boolean(candidate.restarted),
|
|
265
|
+
stopped: candidate.stopped,
|
|
266
|
+
pid: child.pid,
|
|
267
|
+
runtimeDir,
|
|
268
|
+
agintiflowHome: homeDir,
|
|
269
|
+
...candidate,
|
|
270
|
+
}
|
|
121
271
|
: { ok: false, error: `Started web process ${child.pid}, but ${candidate.url}/health did not become ready.`, url: "" };
|
|
122
272
|
}
|
package/web.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
2
3
|
import express from "express";
|
|
3
4
|
import path from "node:path";
|
|
@@ -43,6 +44,7 @@ import {
|
|
|
43
44
|
countUnreadArtifacts,
|
|
44
45
|
findArtifact,
|
|
45
46
|
readArtifactContent,
|
|
47
|
+
resolveArtifactFile,
|
|
46
48
|
serializeArtifacts,
|
|
47
49
|
} from "./src/artifact-tunnel.js";
|
|
48
50
|
|
|
@@ -75,6 +77,17 @@ function isSafeSessionId(sessionId) {
|
|
|
75
77
|
return /^[A-Za-z0-9._:-]+$/.test(String(sessionId || "")) && !String(sessionId || "").includes("..");
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
function safeDownloadFilename(value) {
|
|
81
|
+
const basename = path.basename(String(value || "artifact").replace(/\\/g, "/"));
|
|
82
|
+
return (
|
|
83
|
+
basename
|
|
84
|
+
.replace(/[\r\n"\\]+/g, "_")
|
|
85
|
+
.replace(/^\.+/, "")
|
|
86
|
+
.replace(/[^A-Za-z0-9._ -]+/g, "-")
|
|
87
|
+
.slice(0, 120) || "artifact"
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
78
91
|
function mapEventLogs(events) {
|
|
79
92
|
return events.map((event) => ({
|
|
80
93
|
at: event.timestamp,
|
|
@@ -877,6 +890,39 @@ app.get("/api/sessions/:sessionId/artifacts/:artifactId", async (req, res) => {
|
|
|
877
890
|
res.json(content);
|
|
878
891
|
});
|
|
879
892
|
|
|
893
|
+
app.get("/api/sessions/:sessionId/artifacts/:artifactId/raw", async (req, res) => {
|
|
894
|
+
const bundle = await loadArtifactBundle(req.params.sessionId);
|
|
895
|
+
if (bundle.error) {
|
|
896
|
+
res.status(bundle.status || 500).json({ error: bundle.error });
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const artifact = findArtifact(bundle.items, req.params.artifactId);
|
|
901
|
+
const file = await resolveArtifactFile(artifact, bundle);
|
|
902
|
+
if (!file.ok) {
|
|
903
|
+
res.status(artifact ? 400 : 404).json({ error: file.error || "Artifact not found." });
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const filename = safeDownloadFilename(file.path || file.title || file.absolutePath);
|
|
908
|
+
const disposition = req.query.download === "1" ? "attachment" : "inline";
|
|
909
|
+
res.setHeader("Content-Type", file.mime || "application/octet-stream");
|
|
910
|
+
res.setHeader("Content-Length", String(file.size));
|
|
911
|
+
res.setHeader("Content-Disposition", `${disposition}; filename="${filename}"`);
|
|
912
|
+
res.setHeader("Cache-Control", "private, max-age=0, no-cache");
|
|
913
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
914
|
+
|
|
915
|
+
const stream = createReadStream(file.absolutePath);
|
|
916
|
+
stream.on("error", (error) => {
|
|
917
|
+
if (!res.headersSent) {
|
|
918
|
+
res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
res.destroy(error);
|
|
922
|
+
});
|
|
923
|
+
stream.pipe(res);
|
|
924
|
+
});
|
|
925
|
+
|
|
880
926
|
app.post("/api/sessions/:sessionId/artifacts/select", async (req, res) => {
|
|
881
927
|
const bundle = await loadArtifactBundle(req.params.sessionId);
|
|
882
928
|
if (bundle.error) {
|
|
@@ -1337,7 +1383,21 @@ app.post("/api/runs/:sessionId/stop", async (req, res) => {
|
|
|
1337
1383
|
});
|
|
1338
1384
|
|
|
1339
1385
|
app.get("/health", (_req, res) => {
|
|
1340
|
-
res.json({
|
|
1386
|
+
res.json({
|
|
1387
|
+
ok: true,
|
|
1388
|
+
app: "agintiflow",
|
|
1389
|
+
version: packageJson.version,
|
|
1390
|
+
pid: process.pid,
|
|
1391
|
+
host,
|
|
1392
|
+
port,
|
|
1393
|
+
url: `http://${host}:${port}`,
|
|
1394
|
+
runtimeDir: baseDir,
|
|
1395
|
+
projectRoot: baseDir,
|
|
1396
|
+
agintiflowHome: storagePaths.agintiflowHome,
|
|
1397
|
+
sessionsDir,
|
|
1398
|
+
projectSessionsDir,
|
|
1399
|
+
packageDir,
|
|
1400
|
+
});
|
|
1341
1401
|
});
|
|
1342
1402
|
|
|
1343
1403
|
await fs.mkdir(sessionsDir, { recursive: true });
|