@alisio/server 0.1.0-alpha.5 → 0.1.0-alpha.6
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/dist/auth/view-token.d.ts +16 -0
- package/dist/auth/view-token.js +36 -0
- package/dist/bridges/approval-bridge.js +4 -0
- package/dist/host/presets.js +4 -0
- package/dist/host/run-scheduler.d.ts +12 -2
- package/dist/host/run-scheduler.js +16 -7
- package/dist/host/sessions.js +1 -0
- package/dist/host/workspace-host.js +3 -3
- package/dist/http/errors.js +6 -0
- package/dist/http/router.d.ts +4 -1
- package/dist/http/router.js +30 -9
- package/dist/index.js +67 -5
- package/dist/routes/artifact-view.d.ts +17 -0
- package/dist/routes/artifact-view.js +78 -0
- package/dist/routes/artifacts.d.ts +26 -0
- package/dist/routes/artifacts.js +261 -0
- package/dist/routes/capabilities.d.ts +13 -0
- package/dist/routes/capabilities.js +20 -0
- package/dist/routes/commands.js +24 -0
- package/dist/routes/datasets.d.ts +14 -0
- package/dist/routes/datasets.js +239 -0
- package/dist/routes/files.js +24 -16
- package/dist/routes/management.js +25 -3
- package/dist/routes/prompts.js +26 -3
- package/dist/web/assets/AgentPicker-Bk3zH3g8.js +1 -0
- package/dist/web/assets/AgentsModal-83G5epnE.js +2 -0
- package/dist/web/assets/ArtifactPanel-Bfya9VQE.js +2 -0
- package/dist/web/assets/ArtifactPanel-Cfi5Lgi8.css +1 -0
- package/dist/web/assets/BtwPanel-D_zMyGO-.js +1 -0
- package/dist/web/assets/SettingsModal-BlaG_nXd.js +1 -0
- package/dist/web/assets/SpreadsheetPanel-CYyWl0en.css +1 -0
- package/dist/web/assets/SpreadsheetPanel-CyZ3OzjL.js +1 -0
- package/dist/web/assets/{abnfDiagram-VCTEODGH-Cz5hn5ec.js → abnfDiagram-VCTEODGH-ZCfzpbWr.js} +1 -1
- package/dist/web/assets/{agents.module-lYyQTI6Z.js → agents.module-Bo5TOVLI.js} +1 -1
- package/dist/web/assets/app-DQcjjwqU.js +2 -0
- package/dist/web/assets/architecture-7GRP2DOG-CVMM8Lj-.js +1 -0
- package/dist/web/assets/{architectureDiagram-5GKGNRK7-CSWMHXJ1.js → architectureDiagram-5GKGNRK7-Er3jkF25.js} +1 -1
- package/dist/web/assets/{blockDiagram-I7D4REHJ-CV034Tti.js → blockDiagram-I7D4REHJ-Be2hT-mL.js} +1 -1
- package/dist/web/assets/{c4Diagram-7LVT6UL2-DxMgBNf1.js → c4Diagram-7LVT6UL2-Duo3Ntiy.js} +1 -1
- package/dist/web/assets/channel-4Hu5dyju.js +1 -0
- package/dist/web/assets/{chunk-4HAMMTFA-CT8liele.js → chunk-4HAMMTFA-7Uy7YyT4.js} +1 -1
- package/dist/web/assets/{chunk-75Z2AOVW-DFYQUeQA.js → chunk-75Z2AOVW-B3m56GmF.js} +1 -1
- package/dist/web/assets/{chunk-DU6HZSFF-BJuBFcwd.js → chunk-DU6HZSFF-DcTD2S7l.js} +1 -1
- package/dist/web/assets/{chunk-F27PBJKO-DQKhFLQx.js → chunk-F27PBJKO-CMY7CyPT.js} +1 -1
- package/dist/web/assets/{chunk-GMAD6QVW-DOqNcbFg.js → chunk-GMAD6QVW-BHJbmdX5.js} +1 -1
- package/dist/web/assets/{chunk-GVQU2GXP-uaJ9Q1Go.js → chunk-GVQU2GXP-BBhsNkK3.js} +1 -1
- package/dist/web/assets/{chunk-IMKFNOWR-C85sNyla.js → chunk-IMKFNOWR-B3rc5hnO.js} +1 -1
- package/dist/web/assets/{chunk-L3NEJ4N5-VqLWue5E.js → chunk-L3NEJ4N5-DimjJl8Z.js} +1 -1
- package/dist/web/assets/{chunk-OSK3NFVY-CpDZTLO2.js → chunk-OSK3NFVY-CNxLnYs2.js} +1 -1
- package/dist/web/assets/{chunk-P2QGCYS3-8aJ3lrqy.js → chunk-P2QGCYS3-Cf-3IUFG.js} +1 -1
- package/dist/web/assets/{chunk-POPQ4Y6H-CCGI6bTT.js → chunk-POPQ4Y6H-BG9Qt3Qs.js} +1 -1
- package/dist/web/assets/{chunk-PWAF6VOD-BzM7cl8Q.js → chunk-PWAF6VOD-CPnBYpek.js} +1 -1
- package/dist/web/assets/{chunk-SHT3W25Y-DB0C0Tk9.js → chunk-SHT3W25Y-DXX-R41C.js} +1 -1
- package/dist/web/assets/{chunk-SVP7TREG-CaN0-mdN.js → chunk-SVP7TREG-CUNHXDJu.js} +1 -1
- package/dist/web/assets/{chunk-TICWLB2K-C0M4j05X.js → chunk-TICWLB2K-DBWztQ2y.js} +1 -1
- package/dist/web/assets/classDiagram-ZZMXUADV-31NgaTrI.js +1 -0
- package/dist/web/assets/classDiagram-v2-VYDZK3BY-31NgaTrI.js +1 -0
- package/dist/web/assets/clipboard-hOiC7lD8.js +1 -0
- package/dist/web/assets/{cynefin-OW5HDTMX-r5xVJ9v1.js → cynefin-OW5HDTMX-C3V7POQ0.js} +1 -1
- package/dist/web/assets/{cynefinDiagram-5FMLGOSQ-CkEacFFF.js → cynefinDiagram-5FMLGOSQ-DK4KPgne.js} +1 -1
- package/dist/web/assets/{dagre-GXQ25YYZ-Cpg1w4D6.js → dagre-GXQ25YYZ-BWFzbDLm.js} +1 -1
- package/dist/web/assets/{diagram-S7CK7UJ4-CxzdVKBq.js → diagram-S7CK7UJ4-IQJCXIg3.js} +1 -1
- package/dist/web/assets/{diagram-UQ7AKVKN-BtZLF6HT.js → diagram-UQ7AKVKN-BRwd381n.js} +1 -1
- package/dist/web/assets/{diagram-VSXAHHWV-fSwq53_o.js → diagram-VSXAHHWV-C4W_EeOh.js} +1 -1
- package/dist/web/assets/{diagram-VX7I27RA-p_z4ar7w.js → diagram-VX7I27RA-ke8pqsQe.js} +1 -1
- package/dist/web/assets/{diagram-Z3DM3KII-DvIxMDwh.js → diagram-Z3DM3KII-DsW2mEXH.js} +1 -1
- package/dist/web/assets/{ebnfDiagram-PWID7BFC-kUnSJleQ.js → ebnfDiagram-PWID7BFC-VzNspSlE.js} +1 -1
- package/dist/web/assets/{engine-CuFplYJ5.js → engine-tyooyxGW.js} +4 -4
- package/dist/web/assets/{erDiagram-RLTQ6QDP-BRDWsV60.js → erDiagram-RLTQ6QDP-FJubmRAI.js} +1 -1
- package/dist/web/assets/eventmodeling-NTZA5JFV-MNlDhFce.js +1 -0
- package/dist/web/assets/flowDiagram-HODETNUW-XgsCJ3mL.js +1 -0
- package/dist/web/assets/{ganttDiagram-EL5Y4UJY-DUOezeyk.js → ganttDiagram-EL5Y4UJY-MQGA-IOX.js} +1 -1
- package/dist/web/assets/{gitGraph-4MIJSDKK-DdGj4H6p.js → gitGraph-4MIJSDKK-gKamc5IF.js} +1 -1
- package/dist/web/assets/{gitGraphDiagram-WWUBYQGX-DQ_wu8-N.js → gitGraphDiagram-WWUBYQGX-BzdhYUv6.js} +1 -1
- package/dist/web/assets/{highlight-Coezwnqq.js → highlight-BOXajZiP.js} +1 -1
- package/dist/web/assets/icons-D-VrryMK.js +1 -0
- package/dist/web/assets/index-BDsyWLez.js +74 -0
- package/dist/web/assets/index-DBmyj0tz.css +1 -0
- package/dist/web/assets/{info-A6RAGUB7-bzlyOEbS.js → info-A6RAGUB7-DuYPtkmi.js} +1 -1
- package/dist/web/assets/{infoDiagram-27XIBGKW-DMPIkM67.js → infoDiagram-27XIBGKW-BB4lSxpg.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-5VMMS53U-Dn1_RFxe.js → ishikawaDiagram-5VMMS53U-i4QufZxo.js} +1 -1
- package/dist/web/assets/{journeyDiagram-3NMN7TZE-BZNgHBt7.js → journeyDiagram-3NMN7TZE-BI3hRMTK.js} +1 -1
- package/dist/web/assets/jsxRuntime-Cc8LpgKh.js +5 -0
- package/dist/web/assets/{kanban-definition-UXKFOSKX-5UeB0sIz.js → kanban-definition-UXKFOSKX-BT8ThXz_.js} +1 -1
- package/dist/web/assets/{line-CESetX3B.js → line-CUVacLsr.js} +1 -1
- package/dist/web/assets/{mermaid-parser.core-9beuOH8q.js → mermaid-parser.core-Bzx4uaQN.js} +3 -3
- package/dist/web/assets/{mindmap-definition-YA3MSWOX-BgWvbpzs.js → mindmap-definition-YA3MSWOX-DLjFwkxW.js} +1 -1
- package/dist/web/assets/{packet-AYTQ26CC-Clm_VEbL.js → packet-AYTQ26CC-DVOl-28O.js} +1 -1
- package/dist/web/assets/{pegDiagram-XKGWAZYB-D78fyGXR.js → pegDiagram-XKGWAZYB-Bw-D5JQO.js} +1 -1
- package/dist/web/assets/{pie-WAS4IAKB-BjnIB8bI.js → pie-WAS4IAKB-B1tvMrZ_.js} +1 -1
- package/dist/web/assets/{pieDiagram-E7YTZNPT-k3CAPrzW.js → pieDiagram-E7YTZNPT-EgQzZ2BU.js} +1 -1
- package/dist/web/assets/{quadrantDiagram-AXDQQJYC-Cj3pONTC.js → quadrantDiagram-AXDQQJYC-BKjURXgM.js} +1 -1
- package/dist/web/assets/{radar-RG4KPBEZ-BHTSDlOu.js → radar-RG4KPBEZ-Dw9g28GJ.js} +1 -1
- package/dist/web/assets/{railroad-74A4TZTK-D2sY5w8h.js → railroad-74A4TZTK-gLMAp0cj.js} +1 -1
- package/dist/web/assets/railroad-abnf-HS5TGJTU-U222-YXS.js +1 -0
- package/dist/web/assets/railroad-ebnf-LZEXJU2U-C0blTFnM.js +1 -0
- package/dist/web/assets/railroad-peg-WCYAUIDC-CxdTeRTK.js +1 -0
- package/dist/web/assets/{railroadDiagram-O6MQD6OU-De99_Cdp.js → railroadDiagram-O6MQD6OU-BCWnzF5t.js} +1 -1
- package/dist/web/assets/{requirementDiagram-BXWQKSXE-plwNdlUu.js → requirementDiagram-BXWQKSXE-B6QHLNrN.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-P5KCCOFB-BLX5pbzb.js → sankeyDiagram-P5KCCOFB-Qg1K69Uo.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-WJ2MYXX4-D5L3ResV.js → sequenceDiagram-WJ2MYXX4-CVpZ4gaQ.js} +1 -1
- package/dist/web/assets/shared-But-Cj4O.js +1 -0
- package/dist/web/assets/{stateDiagram-D77RDMKH-B_PLcigs.js → stateDiagram-D77RDMKH-DiCQZB91.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-MP3YSRHH-Cz5VDwGR.js +1 -0
- package/dist/web/assets/{swimlanes-42K2YHIH-BCoy3JfH.js → swimlanes-42K2YHIH-CaG8e8vj.js} +1 -1
- package/dist/web/assets/swimlanesDiagram-VR7AAH4N-CYd640VB.js +8 -0
- package/dist/web/assets/{timeline-definition-24CTP7MA-Dz0_qjiL.js → timeline-definition-24CTP7MA-Cq-1VOyG.js} +1 -1
- package/dist/web/assets/{treeView-Q6P3EWNA-rMSggC0A.js → treeView-Q6P3EWNA-Cd9ldCBt.js} +1 -1
- package/dist/web/assets/{treemap-WGGIJYW6-DSgqT8Si.js → treemap-WGGIJYW6-DehPupET.js} +1 -1
- package/dist/web/assets/{vennDiagram-4TSXK5OY-OZgwexj9.js → vennDiagram-4TSXK5OY-Cthd98xs.js} +1 -1
- package/dist/web/assets/view-6T63R6M0.js +1 -0
- package/dist/web/assets/view-BBCeezQb.js +1 -0
- package/dist/web/assets/view-Bsb5MLyn.js +2 -0
- package/dist/web/assets/view-CHnEurd2.js +1 -0
- package/dist/web/assets/{view-DCdLCLXZ.js → view-CWhobt2H.js} +2 -2
- package/dist/web/assets/view-CXC4z7HA.js +1 -0
- package/dist/web/assets/{view-DCcRnXKN.js → view-CbTw7vda.js} +1 -1
- package/dist/web/assets/{view-PaHzu9kV.js → view-DOUxehse.js} +2 -2
- package/dist/web/assets/{view-BYuuM7qn.js → view-DyTui1_W.js} +1 -1
- package/dist/web/assets/{wardley-WFR3VGLG-BXO-hnJx.js → wardley-WFR3VGLG-D8T7zBoi.js} +1 -1
- package/dist/web/assets/{wardleyDiagram-VM6X3IG4-CJ_kVeKc.js → wardleyDiagram-VM6X3IG4-Bb8LryeH.js} +1 -1
- package/dist/web/assets/{xychartDiagram-S5SC5T6Z-DULIcOoX.js → xychartDiagram-S5SC5T6Z-GUfP1Jf5.js} +1 -1
- package/dist/web/index.html +6 -4
- package/package.json +3 -3
- package/dist/web/assets/AgentPicker-vmCkZAEg.js +0 -1
- package/dist/web/assets/AgentsModal-SDE7h8Ov.js +0 -2
- package/dist/web/assets/BtwPanel-C3is5T--.js +0 -1
- package/dist/web/assets/SettingsModal-CcH378D6.js +0 -1
- package/dist/web/assets/architecture-7GRP2DOG-Bsf7Btg2.js +0 -1
- package/dist/web/assets/channel-DagxYswy.js +0 -1
- package/dist/web/assets/classDiagram-ZZMXUADV-DKXTlMcs.js +0 -1
- package/dist/web/assets/classDiagram-v2-VYDZK3BY-DKXTlMcs.js +0 -1
- package/dist/web/assets/eventmodeling-NTZA5JFV-B9LemJL3.js +0 -1
- package/dist/web/assets/flowDiagram-HODETNUW-Dr1bc3bC.js +0 -1
- package/dist/web/assets/icons-BuSI35ng.js +0 -1
- package/dist/web/assets/index-DFURIQeX.js +0 -75
- package/dist/web/assets/index-Duk7lhBO.css +0 -1
- package/dist/web/assets/jsxRuntime-B237YiZZ.js +0 -5
- package/dist/web/assets/railroad-abnf-HS5TGJTU-By7JhmIc.js +0 -1
- package/dist/web/assets/railroad-ebnf-LZEXJU2U-XSOf4PPv.js +0 -1
- package/dist/web/assets/railroad-peg-WCYAUIDC-Bj0KcXv3.js +0 -1
- package/dist/web/assets/shared-sr65xU-a.js +0 -1
- package/dist/web/assets/stateDiagram-v2-MP3YSRHH-Cgy-1bb9.js +0 -1
- package/dist/web/assets/swimlanesDiagram-VR7AAH4N-DECB5qyv.js +0 -8
- package/dist/web/assets/view-0w3H4M8J.js +0 -2
- package/dist/web/assets/view-CFknHmXR.js +0 -1
- package/dist/web/assets/view-CtWHa3Z4.js +0 -1
- package/dist/web/assets/view-DMJ8HX8Y.js +0 -1
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Published artifacts (spec §14.1): the list of a root session, one artifact with its files and
|
|
3
|
+
* provenance, its download (the file, or a ZIP of a multi-file artifact streamed with
|
|
4
|
+
* `node:zlib`) and, from phase 2, single files for the panel, signed links for the isolated
|
|
5
|
+
* viewer, the copy into the workspace (a tool call of the session), the analysis sources and
|
|
6
|
+
* deletion. Every route is behind the session cookie like the rest of `/api`.
|
|
7
|
+
*/
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { pipeline } from "node:stream/promises";
|
|
12
|
+
import { artifactFileType, artifactSlug, readArtifactHead, toRef, writeZip, } from "@alisio/core";
|
|
13
|
+
import { signViewToken, VIEW_TOKEN_TTL_MS } from "../auth/view-token.js";
|
|
14
|
+
import { readJson } from "../http/body.js";
|
|
15
|
+
import { HttpError } from "../http/errors.js";
|
|
16
|
+
import { is, queryInt, validate } from "../schemas.js";
|
|
17
|
+
const KINDS = [
|
|
18
|
+
"dashboard",
|
|
19
|
+
"document",
|
|
20
|
+
"spreadsheet",
|
|
21
|
+
"image",
|
|
22
|
+
"data",
|
|
23
|
+
"code",
|
|
24
|
+
"archive",
|
|
25
|
+
"file",
|
|
26
|
+
];
|
|
27
|
+
/** Provenance keys that only serve the store (never sent to clients). */
|
|
28
|
+
const PRIVATE = new Set(["sourcePath", "previewable", "sha256"]);
|
|
29
|
+
/** `attachment; filename="…"; filename*=UTF-8''…` with an ASCII fallback (RFC 6266/5987). */
|
|
30
|
+
export function attachmentDisposition(fileName) {
|
|
31
|
+
const ascii = fileName.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
|
|
32
|
+
const encoded = encodeURIComponent(fileName).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
33
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
|
|
34
|
+
}
|
|
35
|
+
/** The same header with `inline` (files the panel previews). */
|
|
36
|
+
const inlineDisposition = (fileName) => attachmentDisposition(fileName).replace(/^attachment/, "inline");
|
|
37
|
+
/**
|
|
38
|
+
* CSP of files served to the panel on the app origin: inert when opened as a document (opaque
|
|
39
|
+
* origin, no scripts), harmless for `<img>` and `fetch()` which ignore it.
|
|
40
|
+
*/
|
|
41
|
+
export const FILE_CSP = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox";
|
|
42
|
+
/** `/artifact-view/<token>/<path>` with every path segment encoded. */
|
|
43
|
+
export const viewUrl = (token, path) => `/artifact-view/${token}/${path.split("/").map(encodeURIComponent).join("/")}`;
|
|
44
|
+
/** Whether the isolated viewer serves an artifact (HTML dashboards and PDFs). */
|
|
45
|
+
export const viewable = (record) => record.kind === "dashboard" || record.mimeType === "application/pdf";
|
|
46
|
+
export function registerArtifactRoutes(router, ctx) {
|
|
47
|
+
const { artifacts, sessions } = ctx;
|
|
48
|
+
const now = ctx.now ?? Date.now;
|
|
49
|
+
const ready = (id) => {
|
|
50
|
+
const record = find(id);
|
|
51
|
+
if (record.status !== "ready")
|
|
52
|
+
throw new HttpError("artifact_not_found", `Artifact ${record.status}`);
|
|
53
|
+
return record;
|
|
54
|
+
};
|
|
55
|
+
function find(id) {
|
|
56
|
+
const record = artifacts.get(id);
|
|
57
|
+
if (!record)
|
|
58
|
+
throw new HttpError("artifact_not_found", "Artifact not found");
|
|
59
|
+
return record;
|
|
60
|
+
}
|
|
61
|
+
router.get("/api/sessions/:sid/artifacts", ({ params, url }) => {
|
|
62
|
+
const session = sessions.get(params.sid ?? "");
|
|
63
|
+
const kind = url.searchParams.get("kind");
|
|
64
|
+
if (kind && !KINDS.includes(kind))
|
|
65
|
+
throw new HttpError("validation_failed", "Invalid query parameter", { fields: ["kind"] });
|
|
66
|
+
const limit = queryInt(url, "limit", 50, 200) ?? 50;
|
|
67
|
+
const before = queryInt(url, "cursor", 0);
|
|
68
|
+
const records = artifacts.list(sessions.rootOf(session.id), {
|
|
69
|
+
...(kind ? { kind: kind } : {}),
|
|
70
|
+
limit,
|
|
71
|
+
...(before ? { before } : {}),
|
|
72
|
+
includeDeleted: true,
|
|
73
|
+
});
|
|
74
|
+
const last = records.at(-1);
|
|
75
|
+
return {
|
|
76
|
+
body: {
|
|
77
|
+
items: records.map(toRef),
|
|
78
|
+
...(records.length === limit && last ? { next: String(last.createdAt) } : {}),
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
router.get("/api/artifacts/:aid", async ({ params }) => {
|
|
83
|
+
const record = find(params.aid ?? "");
|
|
84
|
+
const files = record.status === "ready"
|
|
85
|
+
? (await artifacts.files(record).catch(() => [])).map(({ path, bytes }) => ({
|
|
86
|
+
path,
|
|
87
|
+
bytes,
|
|
88
|
+
}))
|
|
89
|
+
: [];
|
|
90
|
+
const provenance = Object.fromEntries(Object.entries(record.provenance).filter(([key]) => !PRIVATE.has(key)));
|
|
91
|
+
return {
|
|
92
|
+
body: {
|
|
93
|
+
...toRef(record),
|
|
94
|
+
...(record.entry ? { entry: record.entry } : {}),
|
|
95
|
+
files,
|
|
96
|
+
provenance,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
router.get("/api/artifacts/:aid/download", async ({ params, url, res }) => {
|
|
101
|
+
const record = find(params.aid ?? "");
|
|
102
|
+
if (record.status !== "ready")
|
|
103
|
+
throw new HttpError("artifact_not_found", `Artifact ${record.status}`);
|
|
104
|
+
const files = await artifacts.files(record);
|
|
105
|
+
const manifest = url.searchParams.get("manifest") === "1";
|
|
106
|
+
if (files.length === 1 && !manifest) {
|
|
107
|
+
const [file] = files;
|
|
108
|
+
res.writeHead(200, {
|
|
109
|
+
// Always from the type registry, never from the script; downloads never render inline.
|
|
110
|
+
"Content-Type": record.mimeType,
|
|
111
|
+
"Content-Length": file.bytes,
|
|
112
|
+
"Content-Disposition": attachmentDisposition(record.fileName),
|
|
113
|
+
"Cache-Control": "private, no-store",
|
|
114
|
+
});
|
|
115
|
+
await pipeline(createReadStream(file.abs), res);
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
const name = record.fileName.toLowerCase().endsWith(".zip")
|
|
119
|
+
? record.fileName
|
|
120
|
+
: `${record.fileName.replace(/\.[^.]+$/, "")}.zip`;
|
|
121
|
+
res.writeHead(200, {
|
|
122
|
+
"Content-Type": "application/zip",
|
|
123
|
+
"Content-Disposition": attachmentDisposition(name),
|
|
124
|
+
"Cache-Control": "private, no-store",
|
|
125
|
+
});
|
|
126
|
+
await writeZip([
|
|
127
|
+
...files.map((file) => ({ name: file.path, source: file.abs })),
|
|
128
|
+
...(manifest
|
|
129
|
+
? [{ name: "manifest.json", source: join(artifacts.folder(record), "manifest.json") }]
|
|
130
|
+
: []),
|
|
131
|
+
], res);
|
|
132
|
+
res.end();
|
|
133
|
+
return undefined;
|
|
134
|
+
});
|
|
135
|
+
router.get("/api/artifacts/:aid/files/*", async ({ params, res }) => {
|
|
136
|
+
const record = ready(params.aid ?? "");
|
|
137
|
+
const file = await artifacts.resolveFile(record, params["*"] ?? "");
|
|
138
|
+
if (!file)
|
|
139
|
+
throw new HttpError("artifact_not_found", "File not found in this artifact");
|
|
140
|
+
// The registry type (extension and bytes); HTML and download-only types never render inline.
|
|
141
|
+
const type = artifactFileType(file.path, await readArtifactHead(file.abs));
|
|
142
|
+
const name = file.path.split("/").pop() ?? file.path;
|
|
143
|
+
res.writeHead(200, {
|
|
144
|
+
"Content-Type": type.mimeType,
|
|
145
|
+
"Content-Length": file.bytes,
|
|
146
|
+
"Content-Disposition": type.inline ? inlineDisposition(name) : attachmentDisposition(name),
|
|
147
|
+
"Content-Security-Policy": FILE_CSP,
|
|
148
|
+
"Cache-Control": "private, no-store",
|
|
149
|
+
});
|
|
150
|
+
await pipeline(createReadStream(file.abs), res);
|
|
151
|
+
return undefined;
|
|
152
|
+
});
|
|
153
|
+
router.post("/api/artifacts/:aid/view", ({ params }) => {
|
|
154
|
+
const record = ready(params.aid ?? "");
|
|
155
|
+
if (!viewable(record))
|
|
156
|
+
throw new HttpError("validation_failed", "Only dashboards and PDFs open in the viewer");
|
|
157
|
+
if (!record.previewable)
|
|
158
|
+
throw new HttpError("artifact_too_large", "This artifact is too large to preview");
|
|
159
|
+
if (!ctx.viewSecret)
|
|
160
|
+
throw new HttpError("not_found", "The viewer is not available");
|
|
161
|
+
const expiresAt = now() + VIEW_TOKEN_TTL_MS;
|
|
162
|
+
const token = signViewToken(ctx.viewSecret, record.id, expiresAt);
|
|
163
|
+
return { body: { url: viewUrl(token, record.entry ?? record.fileName), expiresAt } };
|
|
164
|
+
});
|
|
165
|
+
/**
|
|
166
|
+
* Queues a tool call of the artifact's session (no model): the same gates apply (write and
|
|
167
|
+
* capability approvals arrive over SSE like any other). `409 runs_active` while it runs.
|
|
168
|
+
*/
|
|
169
|
+
async function queueToolCall(record, tool, display, unavailable, correlationId) {
|
|
170
|
+
const scheduler = ctx.scheduler;
|
|
171
|
+
if (!scheduler)
|
|
172
|
+
throw new HttpError("not_found", "This action is not available");
|
|
173
|
+
const session = sessions.get(record.sessionId);
|
|
174
|
+
if (sessions.lockedBy(session.id))
|
|
175
|
+
throw new HttpError("session_locked", "Another Alisio process (for example the TUI) is using this session");
|
|
176
|
+
const opened = await sessions.app(session);
|
|
177
|
+
if (scheduler.job(session.id) ||
|
|
178
|
+
scheduler.busy(session.id) ||
|
|
179
|
+
opened.app.runner.isRunning(session.id))
|
|
180
|
+
throw new HttpError("runs_active", "Wait until the session finishes running");
|
|
181
|
+
if (!opened.app.registry.list().some((candidate) => candidate.name === tool.name))
|
|
182
|
+
throw new HttpError("permission_denied", unavailable);
|
|
183
|
+
const { run } = sessions.beginRun({
|
|
184
|
+
id: randomUUID(),
|
|
185
|
+
session: session.id,
|
|
186
|
+
status: "queued",
|
|
187
|
+
correlationId,
|
|
188
|
+
model: session.model,
|
|
189
|
+
});
|
|
190
|
+
const status = scheduler.submit({
|
|
191
|
+
runId: run.id,
|
|
192
|
+
sessionId: session.id,
|
|
193
|
+
workspaceId: opened.id,
|
|
194
|
+
app: opened.app,
|
|
195
|
+
text: "",
|
|
196
|
+
correlationId,
|
|
197
|
+
display,
|
|
198
|
+
tool,
|
|
199
|
+
options: () => sessions.runOptions(session.id),
|
|
200
|
+
});
|
|
201
|
+
return { status: 202, body: { runId: run.id, status } };
|
|
202
|
+
}
|
|
203
|
+
router.post("/api/artifacts/:aid/export", async ({ req, params, correlationId }) => {
|
|
204
|
+
const input = validate(await readJson(req), {
|
|
205
|
+
target: { check: is.string(1024), required: true },
|
|
206
|
+
overwrite: { check: is.boolean() },
|
|
207
|
+
});
|
|
208
|
+
const record = ready(params.aid ?? "");
|
|
209
|
+
return queueToolCall(record, {
|
|
210
|
+
name: "artifact_export",
|
|
211
|
+
input: {
|
|
212
|
+
id: record.id,
|
|
213
|
+
target: input.target,
|
|
214
|
+
...(input.overwrite ? { overwrite: true } : {}),
|
|
215
|
+
},
|
|
216
|
+
}, `Copy ${record.fileName} to the workspace (${input.target.trim() || "."})`, "Copying artifacts is unavailable (the server runs with --read-only or analysis is disabled)", correlationId);
|
|
217
|
+
});
|
|
218
|
+
// Rerun (spec §19): the same script and inputs as a NEW execution with new artifacts. It is a
|
|
219
|
+
// `python_run { rerunOf }` call of the session, so the `analysis.run` capability gate applies
|
|
220
|
+
// (a web approval when nothing allows it) and nothing of the original is overwritten.
|
|
221
|
+
router.post("/api/artifacts/:aid/rerun", async ({ params, correlationId }) => {
|
|
222
|
+
const record = find(params.aid ?? "");
|
|
223
|
+
if (record.status === "deleted")
|
|
224
|
+
throw new HttpError("artifact_not_found", "The artifact was deleted");
|
|
225
|
+
if (!record.executionId)
|
|
226
|
+
throw new HttpError("validation_failed", "This artifact was not produced by python_run, so there is no script to run again");
|
|
227
|
+
return queueToolCall(record, { name: "python_run", input: { rerunOf: record.id } }, `Run the analysis of ${record.fileName} again`, "Running analyses is unavailable (the server runs with --read-only or analysis is disabled)", correlationId);
|
|
228
|
+
});
|
|
229
|
+
router.get("/api/artifacts/:aid/sources", async ({ params, url, res }) => {
|
|
230
|
+
const record = find(params.aid ?? "");
|
|
231
|
+
const job = record.executionId ? ctx.jobs?.get(record.executionId) : undefined;
|
|
232
|
+
if (!job || !ctx.stateRoot)
|
|
233
|
+
throw new HttpError("artifact_not_found", "This artifact has no analysis sources");
|
|
234
|
+
const folder = join(ctx.stateRoot, ...job.relDir.split(/[\\/]/));
|
|
235
|
+
const entries = [
|
|
236
|
+
{ name: "script/main.py", source: join(folder, "script", "main.py") },
|
|
237
|
+
{ name: "job.json", source: join(folder, "job.json") },
|
|
238
|
+
...(url.searchParams.get("logs") === "1"
|
|
239
|
+
? ["stdout.log", "stderr.log"].map((name) => ({
|
|
240
|
+
name: `logs/${name}`,
|
|
241
|
+
source: join(folder, "logs", name),
|
|
242
|
+
}))
|
|
243
|
+
: []),
|
|
244
|
+
].filter((entry) => existsSync(entry.source));
|
|
245
|
+
if (!entries.length)
|
|
246
|
+
throw new HttpError("artifact_not_found", "The analysis sources were removed");
|
|
247
|
+
res.writeHead(200, {
|
|
248
|
+
"Content-Type": "application/zip",
|
|
249
|
+
"Content-Disposition": attachmentDisposition(`${artifactSlug(record.title)}-sources.zip`),
|
|
250
|
+
"Cache-Control": "private, no-store",
|
|
251
|
+
});
|
|
252
|
+
await writeZip(entries, res);
|
|
253
|
+
res.end();
|
|
254
|
+
return undefined;
|
|
255
|
+
});
|
|
256
|
+
router.delete("/api/artifacts/:aid", async ({ params }) => {
|
|
257
|
+
const record = ready(params.aid ?? "");
|
|
258
|
+
await artifacts.delete(record.id);
|
|
259
|
+
return { body: { deleted: true } };
|
|
260
|
+
});
|
|
261
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted capability grants of a root session (spec §14.1): list (live grants first, then
|
|
3
|
+
* recent audit rows) and revoke. A revocation broadcasts `capabilities_changed` to the session.
|
|
4
|
+
*/
|
|
5
|
+
import { type CapabilityGrants } from "@alisio/core";
|
|
6
|
+
import type { ServerFrame } from "@alisio/sdk";
|
|
7
|
+
import type { SessionService } from "../host/sessions.ts";
|
|
8
|
+
import type { Router } from "../http/router.ts";
|
|
9
|
+
export declare function registerCapabilityRoutes(router: Router, ctx: {
|
|
10
|
+
grants: CapabilityGrants;
|
|
11
|
+
sessions: SessionService;
|
|
12
|
+
toSession: (sessionId: string, frame: ServerFrame) => void;
|
|
13
|
+
}): void;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted capability grants of a root session (spec §14.1): list (live grants first, then
|
|
3
|
+
* recent audit rows) and revoke. A revocation broadcasts `capabilities_changed` to the session.
|
|
4
|
+
*/
|
|
5
|
+
import { toGrantWire } from "@alisio/core";
|
|
6
|
+
import { HttpError } from "../http/errors.js";
|
|
7
|
+
export function registerCapabilityRoutes(router, ctx) {
|
|
8
|
+
router.get("/api/sessions/:sid/capabilities", ({ params }) => {
|
|
9
|
+
const session = ctx.sessions.get(params.sid ?? "");
|
|
10
|
+
return { body: { items: ctx.grants.list(session.id).map(toGrantWire) } };
|
|
11
|
+
});
|
|
12
|
+
router.delete("/api/sessions/:sid/capabilities/:gid", ({ params }) => {
|
|
13
|
+
const session = ctx.sessions.get(params.sid ?? "");
|
|
14
|
+
if (!ctx.grants.revoke(session.id, params.gid ?? "", "web"))
|
|
15
|
+
throw new HttpError("not_found", "Grant not found or already revoked");
|
|
16
|
+
const root = ctx.sessions.rootOf(session.id);
|
|
17
|
+
ctx.toSession(root, { t: "capabilities_changed", sessionId: root });
|
|
18
|
+
return { body: { revoked: true } };
|
|
19
|
+
});
|
|
20
|
+
}
|
package/dist/routes/commands.js
CHANGED
|
@@ -111,6 +111,30 @@ export function registerCommandRoutes(router, ctx) {
|
|
|
111
111
|
}
|
|
112
112
|
if (descriptor.name === "help")
|
|
113
113
|
return { output: helpText(catalog.list("web")) };
|
|
114
|
+
if (descriptor.name === "permissions") {
|
|
115
|
+
// Web clients open the permissions popover instead; API callers get the list as text.
|
|
116
|
+
const live = opened.app.capabilityGrants.live(session.id);
|
|
117
|
+
return {
|
|
118
|
+
output: live.length
|
|
119
|
+
? live
|
|
120
|
+
.map((g) => `- ${g.capability} · allowed for this session · since ${new Date(g.createdAt).toISOString()} (${g.source})`)
|
|
121
|
+
.join("\n")
|
|
122
|
+
: "No saved permissions in this session.",
|
|
123
|
+
tone: "notice",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (descriptor.name === "artifacts") {
|
|
127
|
+
// Web clients open the artifact panel instead; API callers get the list as text.
|
|
128
|
+
const records = opened.app.artifacts.list(sessions.rootOf(session.id), { limit: 100 });
|
|
129
|
+
const filter = args.toLowerCase();
|
|
130
|
+
const shown = records.filter((r) => `${r.fileName} ${r.title} ${r.kind}`.toLowerCase().includes(filter));
|
|
131
|
+
return {
|
|
132
|
+
output: shown.length
|
|
133
|
+
? shown.map((r) => `- ${r.fileName} (${r.kind}, ${r.bytes} bytes, ${r.id})`).join("\n")
|
|
134
|
+
: "No artifacts in this session yet.",
|
|
135
|
+
tone: "notice",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
114
138
|
if (descriptor.name === "ask") {
|
|
115
139
|
if (!args)
|
|
116
140
|
throw new Error("Usage: /ask <question>");
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ArtifactStore, type SQLiteStore } from "@alisio/core";
|
|
2
|
+
import type { ServerFrame } from "@alisio/sdk";
|
|
3
|
+
import type { SessionService } from "../host/sessions.ts";
|
|
4
|
+
import { HttpError } from "../http/errors.ts";
|
|
5
|
+
import type { Router } from "../http/router.ts";
|
|
6
|
+
/** The HTTP shape of a data failure. */
|
|
7
|
+
export declare function dataHttpError(error: unknown): HttpError;
|
|
8
|
+
export declare function registerDatasetRoutes(router: Router, ctx: {
|
|
9
|
+
sessions: SessionService;
|
|
10
|
+
catalog: SQLiteStore;
|
|
11
|
+
artifacts: ArtifactStore;
|
|
12
|
+
blobs: import("@alisio/core").BlobStore;
|
|
13
|
+
toSession: (sessionId: string, frame: ServerFrame) => void;
|
|
14
|
+
}): void;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Datasets (spec §14.1, §17): raw upload of a CSV/TSV/JSON/JSONL/XLSX file into a session (kept in
|
|
3
|
+
* the content-addressed blob store, ingested in the background into one SQLite file), the list
|
|
4
|
+
* and schema of a session's datasets, and the keyset-paginated rows `SpreadsheetView` reads. A
|
|
5
|
+
* spreadsheet artifact is ingested lazily on its first preview. Ingestion runs in the data
|
|
6
|
+
* engine process, so the server keeps answering (health, SSE heartbeat) while a file is read.
|
|
7
|
+
*/
|
|
8
|
+
import { createReadStream } from "node:fs";
|
|
9
|
+
import { basename } from "node:path";
|
|
10
|
+
import { pipeline } from "node:stream/promises";
|
|
11
|
+
import { BlobTooLarge, DataError, datasetFormat, PageParamError, toDatasetRef, } from "@alisio/core";
|
|
12
|
+
import { HttpError } from "../http/errors.js";
|
|
13
|
+
import { queryInt } from "../schemas.js";
|
|
14
|
+
import { attachmentDisposition } from "./artifacts.js";
|
|
15
|
+
/** How long `POST /api/artifacts/:aid/dataset` waits for the ingestion before answering 202. */
|
|
16
|
+
const ARTIFACT_WAIT_MS = 15_000;
|
|
17
|
+
/** The HTTP shape of a data failure. */
|
|
18
|
+
export function dataHttpError(error) {
|
|
19
|
+
if (error instanceof HttpError)
|
|
20
|
+
return error;
|
|
21
|
+
if (error instanceof PageParamError)
|
|
22
|
+
return new HttpError("validation_failed", error.message);
|
|
23
|
+
if (error instanceof DataError) {
|
|
24
|
+
switch (error.code) {
|
|
25
|
+
case "not_found":
|
|
26
|
+
return new HttpError("not_found", error.message);
|
|
27
|
+
case "dataset_unsupported":
|
|
28
|
+
return new HttpError("dataset_unsupported", error.message);
|
|
29
|
+
case "query_rejected":
|
|
30
|
+
case "sql_error":
|
|
31
|
+
return new HttpError("query_rejected", error.message);
|
|
32
|
+
case "query_timeout":
|
|
33
|
+
return new HttpError("query_timeout", error.message);
|
|
34
|
+
case "ingest_limit":
|
|
35
|
+
return new HttpError("payload_too_large", error.message);
|
|
36
|
+
case "ingest_invalid":
|
|
37
|
+
return new HttpError("validation_failed", error.message);
|
|
38
|
+
case "cancelled":
|
|
39
|
+
return new HttpError("cancelled", error.message);
|
|
40
|
+
default:
|
|
41
|
+
return new HttpError("internal", error.message);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return new HttpError("internal", error instanceof Error ? error.message : String(error));
|
|
45
|
+
}
|
|
46
|
+
/** `X-File-Name` is percent-encoded (headers are ASCII); falls back to the raw text. */
|
|
47
|
+
function uploadName(header) {
|
|
48
|
+
const raw = Array.isArray(header) ? (header[0] ?? "") : (header ?? "");
|
|
49
|
+
let name = raw;
|
|
50
|
+
try {
|
|
51
|
+
name = decodeURIComponent(raw);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
/* keep the raw header */
|
|
55
|
+
}
|
|
56
|
+
return basename(name.replace(/\\/g, "/")).trim().slice(0, 255);
|
|
57
|
+
}
|
|
58
|
+
export function registerDatasetRoutes(router, ctx) {
|
|
59
|
+
const { sessions } = ctx;
|
|
60
|
+
/** The dataset service of the workspace that owns a root session. */
|
|
61
|
+
async function serviceOf(sessionId) {
|
|
62
|
+
const session = sessions.get(sessionId);
|
|
63
|
+
const opened = await sessions.app(session);
|
|
64
|
+
return {
|
|
65
|
+
datasets: opened.app.datasets,
|
|
66
|
+
analysis: opened.app.config.analysis.enabled,
|
|
67
|
+
workspace: session.workspace,
|
|
68
|
+
root: sessions.rootOf(session.id),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** The record of a dataset id and the service of its owner. */
|
|
72
|
+
async function find(id) {
|
|
73
|
+
const row = ctx.catalog.db
|
|
74
|
+
.prepare("SELECT session, root_session FROM datasets WHERE id=?")
|
|
75
|
+
.get(id);
|
|
76
|
+
if (!row)
|
|
77
|
+
throw new HttpError("not_found", "Dataset not found");
|
|
78
|
+
const { datasets } = await serviceOf(row.root_session);
|
|
79
|
+
const record = datasets.get(id);
|
|
80
|
+
if (!record)
|
|
81
|
+
throw new HttpError("not_found", "Dataset not found");
|
|
82
|
+
return { record, datasets };
|
|
83
|
+
}
|
|
84
|
+
const ingestInBackground = (datasets, root, name, work) => {
|
|
85
|
+
work.then(({ record }) => ctx.toSession(root, { t: "dataset_ready", sessionId: root, dataset: toDatasetRef(record) }), (error) => ctx.toSession(root, {
|
|
86
|
+
t: "dataset_failed",
|
|
87
|
+
sessionId: root,
|
|
88
|
+
name,
|
|
89
|
+
error: error instanceof Error ? error.message : String(error),
|
|
90
|
+
}));
|
|
91
|
+
void datasets;
|
|
92
|
+
};
|
|
93
|
+
router.post("/api/sessions/:sid/datasets", async ({ req, params }) => {
|
|
94
|
+
const target = await serviceOf(params.sid ?? "");
|
|
95
|
+
const type = String(req.headers["content-type"] ?? "")
|
|
96
|
+
.split(";")[0]
|
|
97
|
+
?.trim()
|
|
98
|
+
.toLowerCase();
|
|
99
|
+
if (type !== "application/octet-stream") {
|
|
100
|
+
req.resume();
|
|
101
|
+
throw new HttpError("unsupported_media_type", "Upload the file as the raw body with Content-Type: application/octet-stream");
|
|
102
|
+
}
|
|
103
|
+
const name = uploadName(req.headers["x-file-name"]);
|
|
104
|
+
if (!name || !datasetFormat(name)) {
|
|
105
|
+
req.resume();
|
|
106
|
+
throw new HttpError("dataset_unsupported", "Upload a CSV, TSV, JSON, JSONL or XLSX file (send its name in X-File-Name)");
|
|
107
|
+
}
|
|
108
|
+
if (!target.analysis) {
|
|
109
|
+
req.resume();
|
|
110
|
+
throw new HttpError("validation_failed", "Data analysis is disabled (analysis.enabled)");
|
|
111
|
+
}
|
|
112
|
+
const limit = target.datasets.limits.maxUploadBytes;
|
|
113
|
+
const declared = Number(req.headers["content-length"] ?? Number.NaN);
|
|
114
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
115
|
+
req.resume();
|
|
116
|
+
throw new HttpError("payload_too_large", `The file exceeds ${limit} bytes (analysis.data.maxUploadBytes)`);
|
|
117
|
+
}
|
|
118
|
+
let blob;
|
|
119
|
+
try {
|
|
120
|
+
blob = await ctx.blobs.putStream(req, "application/octet-stream", { maxBytes: limit });
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error instanceof BlobTooLarge) {
|
|
124
|
+
req.resume();
|
|
125
|
+
throw new HttpError("payload_too_large", `The file exceeds ${limit} bytes (analysis.data.maxUploadBytes)`);
|
|
126
|
+
}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
if (blob.bytes === 0)
|
|
130
|
+
throw new HttpError("validation_failed", "The upload is empty");
|
|
131
|
+
const session = sessions.get(params.sid ?? "");
|
|
132
|
+
const work = target.datasets.ingest({
|
|
133
|
+
rootSessionId: target.root,
|
|
134
|
+
sessionId: session.id,
|
|
135
|
+
workspace: target.workspace,
|
|
136
|
+
path: ctx.blobs.path(blob.hash),
|
|
137
|
+
name,
|
|
138
|
+
blobHash: blob.hash,
|
|
139
|
+
});
|
|
140
|
+
// A file already ingested in this session answers immediately.
|
|
141
|
+
const existing = target.datasets.list(target.root).find((d) => d.sha256 === blob.hash);
|
|
142
|
+
work.catch(() => undefined);
|
|
143
|
+
ingestInBackground(target.datasets, target.root, name, work);
|
|
144
|
+
if (existing)
|
|
145
|
+
return {
|
|
146
|
+
status: 200,
|
|
147
|
+
body: { dataset: toDatasetRef(existing), pending: false },
|
|
148
|
+
};
|
|
149
|
+
return { status: 202, body: { dataset: null, pending: true } };
|
|
150
|
+
});
|
|
151
|
+
router.get("/api/sessions/:sid/datasets", async ({ params }) => {
|
|
152
|
+
const target = await serviceOf(params.sid ?? "");
|
|
153
|
+
return { body: { items: target.datasets.list(target.root).map(toDatasetRef) } };
|
|
154
|
+
});
|
|
155
|
+
router.get("/api/datasets/:did", async ({ params }) => {
|
|
156
|
+
const { record, datasets } = await find(params.did ?? "");
|
|
157
|
+
try {
|
|
158
|
+
return { body: datasets.detail(record) };
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
throw dataHttpError(error);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
// The original file of a dataset uploaded from the web (the blob store keeps it untouched).
|
|
165
|
+
router.get("/api/datasets/:did/download", async ({ params, res }) => {
|
|
166
|
+
const { record } = await find(params.did ?? "");
|
|
167
|
+
if (!record.blobHash)
|
|
168
|
+
throw new HttpError("not_found", "This dataset has no uploaded original (use the artifact download)");
|
|
169
|
+
const file = ctx.blobs.path(record.blobHash);
|
|
170
|
+
res.writeHead(200, {
|
|
171
|
+
"Content-Type": "application/octet-stream",
|
|
172
|
+
"Content-Length": record.bytes,
|
|
173
|
+
"Content-Disposition": attachmentDisposition(record.name),
|
|
174
|
+
"Cache-Control": "private, no-store",
|
|
175
|
+
});
|
|
176
|
+
await pipeline(createReadStream(file), res);
|
|
177
|
+
return undefined;
|
|
178
|
+
});
|
|
179
|
+
router.get("/api/datasets/:did/rows", async ({ params, url }) => {
|
|
180
|
+
const { record, datasets } = await find(params.did ?? "");
|
|
181
|
+
const dir = url.searchParams.get("dir");
|
|
182
|
+
if (dir !== null && dir !== "asc" && dir !== "desc")
|
|
183
|
+
throw new HttpError("validation_failed", "Invalid query parameter", { fields: ["dir"] });
|
|
184
|
+
const text = (name) => url.searchParams.get(name) ?? undefined;
|
|
185
|
+
const sheet = text("sheet");
|
|
186
|
+
const after = text("after");
|
|
187
|
+
const sort = text("sort");
|
|
188
|
+
const filter = text("filter");
|
|
189
|
+
const column = text("column");
|
|
190
|
+
try {
|
|
191
|
+
const page = await datasets.page(record, {
|
|
192
|
+
...(sheet !== undefined ? { sheet } : {}),
|
|
193
|
+
...(after ? { after } : {}),
|
|
194
|
+
...(queryInt(url, "offset", 0) ? { offset: queryInt(url, "offset", 0) } : {}),
|
|
195
|
+
limit: queryInt(url, "limit", 200, 500) ?? 200,
|
|
196
|
+
...(sort ? { sort } : {}),
|
|
197
|
+
...(dir ? { dir } : {}),
|
|
198
|
+
...(filter ? { filter } : {}),
|
|
199
|
+
...(column !== undefined ? { column } : {}),
|
|
200
|
+
});
|
|
201
|
+
return { body: page };
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
throw dataHttpError(error);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
// A spreadsheet artifact (CSV/TSV/XLSX) is ingested the first time it is previewed.
|
|
208
|
+
router.post("/api/artifacts/:aid/dataset", async ({ params }) => {
|
|
209
|
+
const record = ctx.artifacts.get(params.aid ?? "");
|
|
210
|
+
if (!record || record.status !== "ready")
|
|
211
|
+
throw new HttpError("artifact_not_found", "Artifact not found");
|
|
212
|
+
if (record.kind !== "spreadsheet")
|
|
213
|
+
throw new HttpError("validation_failed", "Only spreadsheet artifacts open as datasets");
|
|
214
|
+
const file = await ctx.artifacts.resolveFile(record, record.entry ?? record.fileName);
|
|
215
|
+
if (!file)
|
|
216
|
+
throw new HttpError("artifact_not_found", "The artifact file was not found");
|
|
217
|
+
const target = await serviceOf(record.sessionId);
|
|
218
|
+
if (!target.analysis)
|
|
219
|
+
throw new HttpError("validation_failed", "Data analysis is disabled (analysis.enabled)");
|
|
220
|
+
const work = target.datasets.ingest({
|
|
221
|
+
rootSessionId: target.root,
|
|
222
|
+
sessionId: record.sessionId,
|
|
223
|
+
workspace: target.workspace,
|
|
224
|
+
path: file.abs,
|
|
225
|
+
name: record.fileName,
|
|
226
|
+
sourcePath: file.abs,
|
|
227
|
+
});
|
|
228
|
+
work.catch(() => undefined);
|
|
229
|
+
const outcome = await Promise.race([
|
|
230
|
+
work.then((done) => ({ done })),
|
|
231
|
+
new Promise((resolve) => setTimeout(() => resolve({}), ARTIFACT_WAIT_MS)),
|
|
232
|
+
]).catch((error) => {
|
|
233
|
+
throw dataHttpError(error);
|
|
234
|
+
});
|
|
235
|
+
if (!outcome.done)
|
|
236
|
+
return { status: 202, body: { dataset: null, pending: true } };
|
|
237
|
+
return { body: { dataset: toDatasetRef(outcome.done.record), pending: false } };
|
|
238
|
+
});
|
|
239
|
+
}
|
package/dist/routes/files.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { lstat, open, readdir, realpath, stat } from "node:fs/promises";
|
|
8
8
|
import { basename, extname, relative, resolve, sep } from "node:path";
|
|
9
|
-
import { clipLines, inside, safePath, unifiedPatch } from "@alisio/core";
|
|
9
|
+
import { clipLines, exportedPaths, inside, safePath, unifiedPatch, } from "@alisio/core";
|
|
10
10
|
import { gitIgnored, gitRoot, gitStatus, runGit } from "../host/git.js";
|
|
11
11
|
import { HttpError } from "../http/errors.js";
|
|
12
12
|
import { sniffImage } from "./images.js";
|
|
@@ -224,23 +224,31 @@ export function registerFileRoutes(router, ctx) {
|
|
|
224
224
|
const meta = wanted.get(call.id);
|
|
225
225
|
if (!meta)
|
|
226
226
|
continue;
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
227
|
+
const paths = [];
|
|
228
|
+
if (call.name === "artifact_export")
|
|
229
|
+
// A copied artifact can write several files: its result lists them.
|
|
230
|
+
paths.push(...exportedPaths(catalog.callResult(id, call.id)));
|
|
231
|
+
else {
|
|
232
|
+
let path;
|
|
233
|
+
try {
|
|
234
|
+
path = JSON.parse(call.arguments).path;
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (typeof path === "string" && path)
|
|
240
|
+
paths.push(path);
|
|
230
241
|
}
|
|
231
|
-
|
|
232
|
-
|
|
242
|
+
for (const path of paths) {
|
|
243
|
+
const absolute = resolve(workspace, path);
|
|
244
|
+
if (!inside(workspace, absolute))
|
|
245
|
+
continue;
|
|
246
|
+
const rel = toPosix(relative(workspace, absolute));
|
|
247
|
+
const at = meta.startedAt ?? 0;
|
|
248
|
+
const prev = latest.get(rel);
|
|
249
|
+
if (!prev || at >= prev.at)
|
|
250
|
+
latest.set(rel, { ...(meta.runId ? { runId: meta.runId } : {}), at });
|
|
233
251
|
}
|
|
234
|
-
if (typeof path !== "string" || !path)
|
|
235
|
-
continue;
|
|
236
|
-
const absolute = resolve(workspace, path);
|
|
237
|
-
if (!inside(workspace, absolute))
|
|
238
|
-
continue;
|
|
239
|
-
const rel = toPosix(relative(workspace, absolute));
|
|
240
|
-
const at = meta.startedAt ?? 0;
|
|
241
|
-
const prev = latest.get(rel);
|
|
242
|
-
if (!prev || at >= prev.at)
|
|
243
|
-
latest.set(rel, { ...(meta.runId ? { runId: meta.runId } : {}), at });
|
|
244
252
|
}
|
|
245
253
|
}
|
|
246
254
|
const last = page.items.at(-1);
|