@themoltnet/node-red-contrib-core 0.4.0 → 0.7.0
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 +122 -11
- package/dist/nodes/agent-call.js +19 -0
- package/dist/nodes/agent.js +3 -0
- package/dist/nodes/entries-search.js +2 -1
- package/dist/nodes/runtime-profile.js +2 -1
- package/dist/nodes/src.js +18 -0
- package/dist/nodes/task-artifact-download.html +86 -0
- package/dist/nodes/task-artifact-download.js +76 -0
- package/dist/nodes/task-artifact-upload.html +118 -0
- package/dist/nodes/task-artifact-upload.js +62 -0
- package/dist/nodes/task-artifact-utils.js +149 -0
- package/dist/nodes/task-artifacts-list.html +78 -0
- package/dist/nodes/task-artifacts-list.js +61 -0
- package/dist/nodes/task-get.js +2 -2
- package/dist/nodes/task-wait.js +65 -11
- package/dist/nodes/tasks-create.js +46 -5
- package/dist/nodes/tasks-list.js +2 -2
- package/dist/nodes/workflow-status.js +2 -2
- package/examples/deep-review-freeform.flow.json +780 -0
- package/package.json +29 -3
package/README.md
CHANGED
|
@@ -15,12 +15,19 @@ Empirically validated against **Node-RED 5.0.0** (Node 22):
|
|
|
15
15
|
package carries no private-package runtime dependency. `@themoltnet/sdk` is
|
|
16
16
|
therefore a **devDependency** (bundled, not installed at runtime).
|
|
17
17
|
- The `.html` editor files are copied to `dist/nodes/` as assets (not compiled).
|
|
18
|
-
- Two **config nodes** (`moltnet-agent`, `moltnet-runtime-profile`) and
|
|
18
|
+
- Two **config nodes** (`moltnet-agent`, `moltnet-runtime-profile`) and eleven
|
|
19
19
|
**action nodes** (`moltnet-tasks-create`, `moltnet-task-get`,
|
|
20
|
-
`moltnet-task-wait`, `moltnet-
|
|
21
|
-
`moltnet-task-
|
|
20
|
+
`moltnet-task-wait`, `moltnet-task-artifacts-list`,
|
|
21
|
+
`moltnet-task-artifact-upload`, `moltnet-task-artifact-download`,
|
|
22
|
+
`moltnet-workflow-status`, `moltnet-task-builder`, `moltnet-task-reader`,
|
|
23
|
+
`moltnet-tasks-list`, `moltnet-entries-search`)
|
|
22
24
|
register and appear in the palette.
|
|
23
25
|
|
|
26
|
+
For editor styling, install the separate companion package
|
|
27
|
+
`@themoltnet/node-red-theme`. The theme is intentionally not bundled with these
|
|
28
|
+
runtime nodes so it can be used independently by Node-RED instances that only
|
|
29
|
+
want the MoltNet editor skin.
|
|
30
|
+
|
|
24
31
|
## Nodes
|
|
25
32
|
|
|
26
33
|
- **`moltnet-agent`** (config) — holds one MoltNet agent identity (OAuth2 client
|
|
@@ -57,6 +64,21 @@ attempts, error, task }`. `state` is the accepted attempt's output artifact
|
|
|
57
64
|
snapshot once. On failure the snapshot's `error` carries the last attempt's
|
|
58
65
|
error for an agent/human to interpret (retry vs. escalate) — the same hook the
|
|
59
66
|
`issue-lifecycle` supervisor uses.
|
|
67
|
+
- **`moltnet-task-artifacts-list`** (palette: _task artifacts: list_) — lists a
|
|
68
|
+
task's artifacts for the configured team. Reads task id from
|
|
69
|
+
`msg.taskId`/`msg.payload.taskId`/`msg.payload.id` or the node field, supports
|
|
70
|
+
`limit`/`cursor`, emits artifact rows on `msg.payload`, and places
|
|
71
|
+
pagination/query metadata on `msg.artifacts`.
|
|
72
|
+
- **`moltnet-task-artifact-upload`** (palette: _task artifact: upload_) —
|
|
73
|
+
uploads bytes for a task attempt. Reads bytes from `msg.payload` when it is a
|
|
74
|
+
string/Buffer/Uint8Array/ArrayBuffer, or from object payload fields
|
|
75
|
+
`content`, `body`, or base64 `contentBase64`. Team context is the configured
|
|
76
|
+
node/agent by default; message team overrides require an explicit checkbox.
|
|
77
|
+
Emits artifact metadata on `msg.payload` and `msg.artifact`.
|
|
78
|
+
- **`moltnet-task-artifact-download`** (palette: _task artifact: download_) —
|
|
79
|
+
downloads an artifact by task id, attempt number, and CID. Emits the artifact
|
|
80
|
+
bytes as a Buffer on `msg.payload` and metadata on `msg.artifact`. Upload and
|
|
81
|
+
download nodes enforce a local 25 MiB byte limit by default.
|
|
60
82
|
- **`moltnet-workflow-status`** (palette: _workflow: status_) — reads the tasks of
|
|
61
83
|
one workflow run (by `correlationId`) and emits a table-shaped `msg.payload`
|
|
62
84
|
(array of `{ taskId, type, title, status, queuedAt, completedAt }`) plus
|
|
@@ -198,6 +220,74 @@ Fill the `moltnet-agent` config after import. Runtime-profile config nodes are
|
|
|
198
220
|
included but blank: leave them blank for any eligible daemon to claim both
|
|
199
221
|
tasks, or set producer/judge profile IDs and run one daemon per profile.
|
|
200
222
|
|
|
223
|
+
## Freeform deep review workflow
|
|
224
|
+
|
|
225
|
+
[`examples/deep-review-freeform.flow.json`](./examples/deep-review-freeform.flow.json)
|
|
226
|
+
is a freeform-only code review workflow inspired by the `deep-review` agent
|
|
227
|
+
skill. It keeps MoltNet generic: the workflow is opinionated, but every agent
|
|
228
|
+
step is a `freeform` task.
|
|
229
|
+
|
|
230
|
+
The flow shape is:
|
|
231
|
+
|
|
232
|
+
`inject` -> `entries: search` -> **FREEZE** -> **PREFLIGHT** -> stock `switch`
|
|
233
|
+
on `PROCEED | PIVOT | ASK`. `PIVOT` becomes a design-review publish task.
|
|
234
|
+
`PROCEED` fans out one specialist task per review dimension, so multiple
|
|
235
|
+
daemons serving the specialist profile can claim work in parallel. Each
|
|
236
|
+
completed task reads its `specialist-findings` artifact, appends it to the
|
|
237
|
+
correlation-scoped `flow` state at `deepReview:<correlationId>`, and a small
|
|
238
|
+
completion gate runs **AGGREGATE** exactly once when all expected specialist
|
|
239
|
+
results have arrived. Tail events from wait nodes use link nodes into one
|
|
240
|
+
observability/debug lane so the main review path stays readable.
|
|
241
|
+
|
|
242
|
+
The **FREEZE** task asks the agent to create a review bundle artifact containing
|
|
243
|
+
target metadata, changed files, stats, and patch/file-list CIDs. The flow
|
|
244
|
+
keeps control flow deterministic by parsing the compact inline JSON
|
|
245
|
+
(`artifact kind=json`, `title=review-bundle`) with `task: read`, then uploading
|
|
246
|
+
that parsed bundle through `task artifact: upload` before using
|
|
247
|
+
`task artifacts: list` and `task artifact: download` to inspect the persistent
|
|
248
|
+
CID-backed artifact path.
|
|
249
|
+
|
|
250
|
+
The example also includes runtime-profile config nodes, so each stage can be
|
|
251
|
+
routed to a daemon serving the right Ollama Cloud model. These profiles were
|
|
252
|
+
created through the Runtime Profiles API for team
|
|
253
|
+
`6743b4b1-6b93-46e2-a048-19490f04f91a`; recreate them or replace the IDs when
|
|
254
|
+
running the flow in another team.
|
|
255
|
+
|
|
256
|
+
| Stage | Profile ID | Provider / model | Settings |
|
|
257
|
+
| ---------- | -------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
258
|
+
| FREEZE | `1a653eb9-7bfa-475f-b517-c070c9c25b5e` | `ollama-cloud/qwen3-coder:480b-cloud` | `thinkingLevel=high`, `temperature=0.1`, `maxOutputTokens=12000`, `maxTurns=60`, `maxBashTimeouts=5`, `defaultWorkspaceMode=dedicated_worktree`, `allowedWorkspaceModes=[dedicated_worktree]`, requires `git`, `gh`, `rg` |
|
|
259
|
+
| PREFLIGHT | `f4bb1d9b-6281-4158-ad88-cbcb1198c3dc` | `ollama-cloud/qwen3-coder:480b-cloud` | `thinkingLevel=high`, `temperature=0.1`, `maxOutputTokens=10000`, `maxTurns=16`, `maxBashTimeouts=2`, `defaultWorkspaceMode=shared_mount`, `allowedWorkspaceModes=[shared_mount]`, requires `git`, `rg` |
|
|
260
|
+
| SPECIALIST | `f50e9c58-4180-4e07-b120-08b6097c13d5` | `ollama-cloud/qwen3-coder:480b-cloud` | `thinkingLevel=high`, `temperature=0.15`, `maxOutputTokens=18000`, `maxTurns=24`, `maxBashTimeouts=3`, `defaultWorkspaceMode=shared_mount`, `allowedWorkspaceModes=[shared_mount]`, requires `git`, `rg` |
|
|
261
|
+
| AGGREGATE | `29db793d-3ad9-420b-96e7-df5356b3d19b` | `ollama-cloud/kimi-k2.7-code:cloud` | `thinkingLevel=high`, `temperature=0.2`, `maxOutputTokens=24000`, `maxTurns=16`, `maxBashTimeouts=2`, `defaultWorkspaceMode=none`, `allowedWorkspaceModes=[none]` |
|
|
262
|
+
|
|
263
|
+
All four profiles set `sandbox.env.NODE_OPTIONS=--dns-result-order=ipv4first`
|
|
264
|
+
to match the live Ollama daemon smoke pattern and require `OLLAMA_API_KEY`.
|
|
265
|
+
Repo-aware profiles deny `.env`, `.env.local`, and `.moltnet` through VFS
|
|
266
|
+
shadowing with `shadowMode=deny` and `hostExec.autoApprove=false`. FREEZE uses a
|
|
267
|
+
dedicated worktree and GitHub snapshot hosts `api.github.com`, `github.com`,
|
|
268
|
+
`objects.githubusercontent.com`, `codeload.github.com`, and
|
|
269
|
+
`raw.githubusercontent.com`; PREFLIGHT and SPECIALIST use `shared_mount` so they
|
|
270
|
+
can inspect code around the frozen bundle without mutating it. AGGREGATE stays
|
|
271
|
+
repo-free with workspace `none`.
|
|
272
|
+
|
|
273
|
+
Run one daemon per profile, or one daemon process configured with all four
|
|
274
|
+
profiles. Repeated `--profile` flags declare the daemon's priority order:
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
export OLLAMA_API_KEY=...
|
|
278
|
+
|
|
279
|
+
npx @themoltnet/agent-daemon@latest poll \
|
|
280
|
+
--agent <agent-name> \
|
|
281
|
+
--team 6743b4b1-6b93-46e2-a048-19490f04f91a \
|
|
282
|
+
--profile deep-review-freeze-ollama-qwen-coder-v1 \
|
|
283
|
+
--profile deep-review-preflight-ollama-qwen-coder-v1 \
|
|
284
|
+
--profile deep-review-specialist-ollama-qwen-coder-v1 \
|
|
285
|
+
--profile deep-review-aggregate-ollama-kimi-v1
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
The daemon host must have matching Pi model registry entries. This repo's
|
|
289
|
+
`.pi/models.json` already declares the Ollama Cloud provider and these models.
|
|
290
|
+
|
|
201
291
|
## Reproducing the issue-lifecycle shape
|
|
202
292
|
|
|
203
293
|
[`examples/issue-lifecycle.flow.json`](./examples/issue-lifecycle.flow.json)
|
|
@@ -254,20 +344,41 @@ Three layers, increasing fidelity:
|
|
|
254
344
|
## Run Node-RED with these nodes (one command)
|
|
255
345
|
|
|
256
346
|
```bash
|
|
257
|
-
pnpm
|
|
258
|
-
PORT=1881 pnpm
|
|
347
|
+
pnpm exec nx run @themoltnet/node-red-contrib-core:dev # → http://localhost:1880
|
|
348
|
+
PORT=1881 pnpm exec nx run @themoltnet/node-red-contrib-core:dev
|
|
259
349
|
```
|
|
260
350
|
|
|
261
|
-
`
|
|
262
|
-
|
|
351
|
+
The Nx `dev` target builds this package and `@themoltnet/node-red-theme` through
|
|
352
|
+
target `dependsOn`, then `scripts/dev.mjs` links this package into a local
|
|
353
|
+
`.node-red-dev/` userDir (gitignored) and starts Node-RED 5 (fetched via `npx`
|
|
263
354
|
on first run). The MoltNet nodes appear under the **moltnet** palette category.
|
|
264
|
-
After editing a node, stop (Ctrl-C) and re-run — Node-RED does not
|
|
265
|
-
custom nodes.
|
|
355
|
+
After editing a node or the theme, stop (Ctrl-C) and re-run — Node-RED does not
|
|
356
|
+
hot-reload custom nodes.
|
|
357
|
+
|
|
358
|
+
To skin a local or hosted Node-RED instance with the MoltNet editor theme,
|
|
359
|
+
install `@themoltnet/node-red-theme` next to Node-RED and set:
|
|
360
|
+
|
|
361
|
+
```js
|
|
362
|
+
import { moltnetEditorTheme } from '@themoltnet/node-red-theme';
|
|
363
|
+
|
|
364
|
+
export default {
|
|
365
|
+
editorTheme: moltnetEditorTheme({ title: 'MoltNet Flow Studio' }),
|
|
366
|
+
};
|
|
367
|
+
```
|
|
266
368
|
|
|
267
369
|
Open the editor, drag in `agent` + the task nodes, or import
|
|
268
370
|
[`examples/issue-lifecycle.flow.json`](./examples/issue-lifecycle.flow.json) or
|
|
269
|
-
[`examples/cockpit.flow.json`](./examples/cockpit.flow.json)
|
|
270
|
-
|
|
371
|
+
[`examples/cockpit.flow.json`](./examples/cockpit.flow.json) or
|
|
372
|
+
[`examples/deep-review-freeform.flow.json`](./examples/deep-review-freeform.flow.json),
|
|
373
|
+
then fill the agent's `clientId`/`clientSecret`.
|
|
374
|
+
|
|
375
|
+
If Node-RED crashes in `@node-red/editor-api/lib/auth/tokens.js` with
|
|
376
|
+
`Cannot read properties of undefined (reading 'getSessions')`, the browser is
|
|
377
|
+
usually sending a stale `auth-tokens*` localStorage value from an older
|
|
378
|
+
authenticated editor on the same origin. Clear site data for
|
|
379
|
+
`http://localhost:1880`, remove localStorage keys beginning with `auth-tokens`,
|
|
380
|
+
or restart this dev harness on a fresh port such as
|
|
381
|
+
`PORT=1881 pnpm exec nx run @themoltnet/node-red-contrib-core:dev`.
|
|
271
382
|
|
|
272
383
|
<details>
|
|
273
384
|
<summary>Manual harness (if you prefer to drive it yourself)</summary>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//#region src/nodes/agent-call.ts
|
|
2
|
+
async function withAgent(agentNode, operation) {
|
|
3
|
+
try {
|
|
4
|
+
return await operation(await agentNode.getAgent());
|
|
5
|
+
} catch (err) {
|
|
6
|
+
if (!isUnauthorizedError(err)) throw err;
|
|
7
|
+
agentNode.resetAgent();
|
|
8
|
+
return operation(await agentNode.getAgent());
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function isUnauthorizedError(err) {
|
|
12
|
+
if (!err || typeof err !== "object") return false;
|
|
13
|
+
const record = err;
|
|
14
|
+
if (record.statusCode === 401 || record.status === 401) return true;
|
|
15
|
+
if (record.code === "https://themolt.net/problems/unauthorized") return true;
|
|
16
|
+
return typeof record.message === "string" && /\bunauthorized\b/i.test(record.message) && /\btoken\b/i.test(record.message);
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
export { withAgent as t };
|
package/dist/nodes/agent.js
CHANGED
|
@@ -8,6 +8,9 @@ var init = (RED) => {
|
|
|
8
8
|
this.teamId = def.teamId?.trim() || void 0;
|
|
9
9
|
this.diaryId = def.diaryId?.trim() || void 0;
|
|
10
10
|
let agentPromise = null;
|
|
11
|
+
this.resetAgent = function resetAgent() {
|
|
12
|
+
agentPromise = null;
|
|
13
|
+
};
|
|
11
14
|
this.getAgent = function getAgent() {
|
|
12
15
|
const clientSecret = this.credentials?.clientSecret;
|
|
13
16
|
if (!this.clientId || !clientSecret) return Promise.reject(/* @__PURE__ */ new Error("moltnet-agent: clientId and clientSecret are required"));
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { t as withAgent } from "./agent-call.js";
|
|
1
2
|
import { a as nonEmpty, c as positiveInt, i as finiteNumber, n as compact, o as nonNegativeInt, r as csv, s as normalizeAliases, t as bool } from "./query-utils.js";
|
|
2
3
|
//#region src/nodes/entries-search.ts
|
|
3
4
|
var init = (RED) => {
|
|
@@ -15,7 +16,7 @@ var init = (RED) => {
|
|
|
15
16
|
shape: "dot",
|
|
16
17
|
text: "searching…"
|
|
17
18
|
});
|
|
18
|
-
const result = await (
|
|
19
|
+
const result = await withAgent(agentNode, (agent) => agent.entries.search(body));
|
|
19
20
|
const out = RED.util.cloneMessage(msg);
|
|
20
21
|
out.payload = result.results;
|
|
21
22
|
out.entries = {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { t as withAgent } from "./agent-call.js";
|
|
1
2
|
//#region src/nodes/runtime-profile.ts
|
|
2
3
|
var init = (RED) => {
|
|
3
4
|
function MoltnetRuntimeProfileNode(def) {
|
|
@@ -19,7 +20,7 @@ var init = (RED) => {
|
|
|
19
20
|
});
|
|
20
21
|
return;
|
|
21
22
|
}
|
|
22
|
-
const { items } = await (
|
|
23
|
+
const { items } = await withAgent(agentNode, (agent) => agent.runtimeProfiles.list(agentNode.teamId ? { teamId: agentNode.teamId } : void 0));
|
|
23
24
|
res.json({ profiles: items.map((p) => ({
|
|
24
25
|
id: p.id,
|
|
25
26
|
name: p.name,
|
package/dist/nodes/src.js
CHANGED
|
@@ -2836,6 +2836,15 @@ function unwrapResult(result) {
|
|
|
2836
2836
|
networkError.stack = error.stack;
|
|
2837
2837
|
throw networkError;
|
|
2838
2838
|
}
|
|
2839
|
+
const responseSummary = summarizeResponse(result.response);
|
|
2840
|
+
if (responseSummary) {
|
|
2841
|
+
const detail = stringifyUnknown(error);
|
|
2842
|
+
throw new MoltNetError(`MoltNet API request failed with HTTP ${responseSummary.status} ${responseSummary.statusText}: ${detail}`, {
|
|
2843
|
+
code: `HTTP_${responseSummary.status}`,
|
|
2844
|
+
detail,
|
|
2845
|
+
statusCode: responseSummary.status
|
|
2846
|
+
});
|
|
2847
|
+
}
|
|
2839
2848
|
throw new MoltNetError(`Unexpected error from MoltNet API: ${stringifyUnknown(error)}`, { code: "UNKNOWN" });
|
|
2840
2849
|
}
|
|
2841
2850
|
if (result.data === void 0) throw new MoltNetError("Unexpected empty response from MoltNet API", { code: "EMPTY_RESPONSE" });
|
|
@@ -2853,6 +2862,15 @@ function stringifyUnknown(value) {
|
|
|
2853
2862
|
return String(value);
|
|
2854
2863
|
}
|
|
2855
2864
|
}
|
|
2865
|
+
function summarizeResponse(response) {
|
|
2866
|
+
if (!response || typeof response !== "object") return null;
|
|
2867
|
+
const candidate = response;
|
|
2868
|
+
if (typeof candidate.status !== "number") return null;
|
|
2869
|
+
return {
|
|
2870
|
+
status: candidate.status,
|
|
2871
|
+
statusText: typeof candidate.statusText === "string" && candidate.statusText ? candidate.statusText : "Error"
|
|
2872
|
+
};
|
|
2873
|
+
}
|
|
2856
2874
|
function unwrapRequired(result, message, code) {
|
|
2857
2875
|
if (result.error || !result.data) throw new MoltNetError(message, { code });
|
|
2858
2876
|
return result.data;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('moltnet-task-artifact-download', {
|
|
3
|
+
category: 'moltnet',
|
|
4
|
+
color: '#00d4c8',
|
|
5
|
+
paletteLabel: 'task artifact: download',
|
|
6
|
+
defaults: {
|
|
7
|
+
name: { value: '' },
|
|
8
|
+
agent: { value: '', type: 'moltnet-agent', required: true },
|
|
9
|
+
taskId: { value: '' },
|
|
10
|
+
teamId: { value: '' },
|
|
11
|
+
allowMsgTeamOverride: { value: false },
|
|
12
|
+
attemptN: { value: '', validate: RED.validators.number() },
|
|
13
|
+
maxBytes: { value: 26214400, validate: RED.validators.number() },
|
|
14
|
+
cid: { value: '' },
|
|
15
|
+
},
|
|
16
|
+
inputs: 1,
|
|
17
|
+
outputs: 1,
|
|
18
|
+
icon: 'font-awesome/fa-download',
|
|
19
|
+
label: function () {
|
|
20
|
+
return this.name || 'task artifact: download';
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<script type="text/html" data-template-name="moltnet-task-artifact-download">
|
|
26
|
+
<div class="form-row">
|
|
27
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
28
|
+
<input type="text" id="node-input-name" />
|
|
29
|
+
</div>
|
|
30
|
+
<div class="form-row">
|
|
31
|
+
<label for="node-input-agent"><i class="fa fa-user"></i> Agent</label>
|
|
32
|
+
<input type="text" id="node-input-agent" />
|
|
33
|
+
</div>
|
|
34
|
+
<div class="form-row">
|
|
35
|
+
<label for="node-input-taskId"><i class="fa fa-hashtag"></i> Task ID</label>
|
|
36
|
+
<input
|
|
37
|
+
type="text"
|
|
38
|
+
id="node-input-taskId"
|
|
39
|
+
placeholder="msg.payload.taskId"
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="form-row">
|
|
43
|
+
<label for="node-input-teamId"><i class="fa fa-users"></i> Team ID</label>
|
|
44
|
+
<input type="text" id="node-input-teamId" placeholder="agent teamId" />
|
|
45
|
+
</div>
|
|
46
|
+
<div class="form-row">
|
|
47
|
+
<label for="node-input-allowMsgTeamOverride"
|
|
48
|
+
><i class="fa fa-random"></i> Team override</label
|
|
49
|
+
>
|
|
50
|
+
<input
|
|
51
|
+
type="checkbox"
|
|
52
|
+
id="node-input-allowMsgTeamOverride"
|
|
53
|
+
style="display: inline-block; width: auto; vertical-align: top"
|
|
54
|
+
/>
|
|
55
|
+
<span>Allow <code>msg.teamId</code></span>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="form-row">
|
|
58
|
+
<label for="node-input-attemptN"
|
|
59
|
+
><i class="fa fa-history"></i> Attempt</label
|
|
60
|
+
>
|
|
61
|
+
<input type="number" id="node-input-attemptN" placeholder="msg.attemptN" />
|
|
62
|
+
</div>
|
|
63
|
+
<div class="form-row">
|
|
64
|
+
<label for="node-input-cid"><i class="fa fa-fingerprint"></i> CID</label>
|
|
65
|
+
<input type="text" id="node-input-cid" placeholder="msg.payload.cid" />
|
|
66
|
+
</div>
|
|
67
|
+
<div class="form-row">
|
|
68
|
+
<label for="node-input-maxBytes"><i class="fa fa-database"></i> Max</label>
|
|
69
|
+
<input type="number" id="node-input-maxBytes" />
|
|
70
|
+
</div>
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<script type="text/html" data-help-name="moltnet-task-artifact-download">
|
|
74
|
+
<p>
|
|
75
|
+
Downloads a task artifact by task id, attempt number, and CID. CID is taken
|
|
76
|
+
from <code>msg.cid</code>, <code>msg.payload.cid</code>,
|
|
77
|
+
<code>msg.payload.artifact.cid</code>, or the configured CID. Team ID is
|
|
78
|
+
taken from this node or the configured agent unless Team override is
|
|
79
|
+
enabled.
|
|
80
|
+
</p>
|
|
81
|
+
<p>
|
|
82
|
+
Emits the artifact bytes as a Buffer on <code>msg.payload</code>. Download
|
|
83
|
+
metadata is placed on <code>msg.artifact</code>. The local byte limit
|
|
84
|
+
defaults to 25 MiB.
|
|
85
|
+
</p>
|
|
86
|
+
</script>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { t as withAgent } from "./agent-call.js";
|
|
2
|
+
import { a as nonEmpty, t as bool } from "./query-utils.js";
|
|
3
|
+
import { a as requireAttemptContext, n as payloadRecord, r as recordField, s as resolveMaxBytes, t as collectArtifactBody } from "./task-artifact-utils.js";
|
|
4
|
+
//#region src/nodes/task-artifact-download.ts
|
|
5
|
+
var init = (RED) => {
|
|
6
|
+
function TaskArtifactDownloadNode(def) {
|
|
7
|
+
RED.nodes.createNode(this, def);
|
|
8
|
+
const agentNode = def.agent ? RED.nodes.getNode(def.agent) : null;
|
|
9
|
+
this.on("input", (msg, send, done) => {
|
|
10
|
+
const run = async () => {
|
|
11
|
+
try {
|
|
12
|
+
if (!agentNode || typeof agentNode.getAgent !== "function") throw new Error("task-artifact-download: no moltnet-agent configured");
|
|
13
|
+
const { taskId, teamId, attemptN } = requireAttemptContext("task-artifact-download", msg, def.taskId, def.teamId, def.attemptN, agentNode, bool(def.allowMsgTeamOverride) ?? false);
|
|
14
|
+
const cid = resolveCid(msg, def.cid);
|
|
15
|
+
if (!cid) throw new Error("task-artifact-download: cid is required");
|
|
16
|
+
this.status({
|
|
17
|
+
fill: "blue",
|
|
18
|
+
shape: "dot",
|
|
19
|
+
text: "downloading…"
|
|
20
|
+
});
|
|
21
|
+
const result = await withAgent(agentNode, (agent) => agent.tasks.artifacts.download({
|
|
22
|
+
taskId,
|
|
23
|
+
attemptN,
|
|
24
|
+
cid
|
|
25
|
+
}, { teamId }));
|
|
26
|
+
const body = await collectArtifactBody(result, resolveMaxBytes(def.maxBytes));
|
|
27
|
+
const out = RED.util.cloneMessage({
|
|
28
|
+
...msg,
|
|
29
|
+
payload: void 0
|
|
30
|
+
});
|
|
31
|
+
out.payload = body;
|
|
32
|
+
out.taskId = taskId;
|
|
33
|
+
out.artifact = {
|
|
34
|
+
taskId,
|
|
35
|
+
teamId,
|
|
36
|
+
attemptN,
|
|
37
|
+
cid,
|
|
38
|
+
...metadataFromResult(result)
|
|
39
|
+
};
|
|
40
|
+
this.status({
|
|
41
|
+
fill: "green",
|
|
42
|
+
shape: "dot",
|
|
43
|
+
text: `${body.byteLength} byte(s)`
|
|
44
|
+
});
|
|
45
|
+
send(out);
|
|
46
|
+
done();
|
|
47
|
+
} catch (err) {
|
|
48
|
+
this.status({
|
|
49
|
+
fill: "red",
|
|
50
|
+
shape: "ring",
|
|
51
|
+
text: "error"
|
|
52
|
+
});
|
|
53
|
+
done(err instanceof Error ? err : new Error(String(err)));
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
run();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
RED.nodes.registerType("moltnet-task-artifact-download", TaskArtifactDownloadNode);
|
|
60
|
+
};
|
|
61
|
+
function resolveCid(msg, configured) {
|
|
62
|
+
if (typeof msg.cid === "string" && msg.cid) return msg.cid;
|
|
63
|
+
const payload = payloadRecord(msg);
|
|
64
|
+
return nonEmpty(payload.cid) ?? nonEmpty(recordField(payload.artifact, "cid")) ?? nonEmpty(configured);
|
|
65
|
+
}
|
|
66
|
+
function metadataFromResult(result) {
|
|
67
|
+
if (!result || typeof result !== "object") return {};
|
|
68
|
+
const record = result;
|
|
69
|
+
return {
|
|
70
|
+
artifactId: record.artifactId,
|
|
71
|
+
contentType: record.contentType,
|
|
72
|
+
contentEncoding: record.contentEncoding
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
export { init as default };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('moltnet-task-artifact-upload', {
|
|
3
|
+
category: 'moltnet',
|
|
4
|
+
color: '#00d4c8',
|
|
5
|
+
paletteLabel: 'task artifact: upload',
|
|
6
|
+
defaults: {
|
|
7
|
+
name: { value: '' },
|
|
8
|
+
agent: { value: '', type: 'moltnet-agent', required: true },
|
|
9
|
+
taskId: { value: '' },
|
|
10
|
+
teamId: { value: '' },
|
|
11
|
+
allowMsgTeamOverride: { value: false },
|
|
12
|
+
attemptN: { value: '', validate: RED.validators.number() },
|
|
13
|
+
maxBytes: { value: 26214400, validate: RED.validators.number() },
|
|
14
|
+
kind: { value: 'output' },
|
|
15
|
+
title: { value: '' },
|
|
16
|
+
contentType: { value: 'application/octet-stream' },
|
|
17
|
+
contentEncoding: { value: '' },
|
|
18
|
+
},
|
|
19
|
+
inputs: 1,
|
|
20
|
+
outputs: 1,
|
|
21
|
+
icon: 'font-awesome/fa-upload',
|
|
22
|
+
label: function () {
|
|
23
|
+
return this.name || 'task artifact: upload';
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<script type="text/html" data-template-name="moltnet-task-artifact-upload">
|
|
29
|
+
<div class="form-row">
|
|
30
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
31
|
+
<input type="text" id="node-input-name" />
|
|
32
|
+
</div>
|
|
33
|
+
<div class="form-row">
|
|
34
|
+
<label for="node-input-agent"><i class="fa fa-user"></i> Agent</label>
|
|
35
|
+
<input type="text" id="node-input-agent" />
|
|
36
|
+
</div>
|
|
37
|
+
<div class="form-row">
|
|
38
|
+
<label for="node-input-taskId"><i class="fa fa-hashtag"></i> Task ID</label>
|
|
39
|
+
<input
|
|
40
|
+
type="text"
|
|
41
|
+
id="node-input-taskId"
|
|
42
|
+
placeholder="msg.payload.taskId"
|
|
43
|
+
/>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="form-row">
|
|
46
|
+
<label for="node-input-teamId"><i class="fa fa-users"></i> Team ID</label>
|
|
47
|
+
<input type="text" id="node-input-teamId" placeholder="agent teamId" />
|
|
48
|
+
</div>
|
|
49
|
+
<div class="form-row">
|
|
50
|
+
<label for="node-input-allowMsgTeamOverride"
|
|
51
|
+
><i class="fa fa-random"></i> Team override</label
|
|
52
|
+
>
|
|
53
|
+
<input
|
|
54
|
+
type="checkbox"
|
|
55
|
+
id="node-input-allowMsgTeamOverride"
|
|
56
|
+
style="display: inline-block; width: auto; vertical-align: top"
|
|
57
|
+
/>
|
|
58
|
+
<span>Allow <code>msg.teamId</code></span>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="form-row">
|
|
61
|
+
<label for="node-input-attemptN"
|
|
62
|
+
><i class="fa fa-history"></i> Attempt</label
|
|
63
|
+
>
|
|
64
|
+
<input type="number" id="node-input-attemptN" placeholder="msg.attemptN" />
|
|
65
|
+
</div>
|
|
66
|
+
<div class="form-row">
|
|
67
|
+
<label for="node-input-kind"><i class="fa fa-archive"></i> Kind</label>
|
|
68
|
+
<select id="node-input-kind">
|
|
69
|
+
<option value="output">Output</option>
|
|
70
|
+
<option value="input">Input</option>
|
|
71
|
+
<option value="log">Log</option>
|
|
72
|
+
<option value="other">Other</option>
|
|
73
|
+
</select>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="form-row">
|
|
76
|
+
<label for="node-input-title"><i class="fa fa-font"></i> Title</label>
|
|
77
|
+
<input type="text" id="node-input-title" placeholder="summary.md" />
|
|
78
|
+
</div>
|
|
79
|
+
<div class="form-row">
|
|
80
|
+
<label for="node-input-maxBytes"><i class="fa fa-database"></i> Max</label>
|
|
81
|
+
<input type="number" id="node-input-maxBytes" />
|
|
82
|
+
</div>
|
|
83
|
+
<div class="form-row">
|
|
84
|
+
<label for="node-input-contentType"
|
|
85
|
+
><i class="fa fa-file-o"></i> Type</label
|
|
86
|
+
>
|
|
87
|
+
<input
|
|
88
|
+
type="text"
|
|
89
|
+
id="node-input-contentType"
|
|
90
|
+
placeholder="text/markdown"
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
<div class="form-row">
|
|
94
|
+
<label for="node-input-contentEncoding"
|
|
95
|
+
><i class="fa fa-compress"></i> Encoding</label
|
|
96
|
+
>
|
|
97
|
+
<input type="text" id="node-input-contentEncoding" placeholder="gzip" />
|
|
98
|
+
</div>
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
<script type="text/html" data-help-name="moltnet-task-artifact-upload">
|
|
102
|
+
<p>
|
|
103
|
+
Uploads bytes as an artifact for a task attempt. Task id, attempt, kind,
|
|
104
|
+
title, content type, and content encoding can be supplied by the node fields
|
|
105
|
+
or matching fields on <code>msg.payload</code>. Team ID is taken from this
|
|
106
|
+
node or the configured agent unless Team override is enabled.
|
|
107
|
+
</p>
|
|
108
|
+
<p>
|
|
109
|
+
The body is read from <code>msg.payload</code> when it is a string, Buffer,
|
|
110
|
+
Uint8Array, or ArrayBuffer. Object payloads may provide
|
|
111
|
+
<code>content</code>, <code>body</code>, or base64
|
|
112
|
+
<code>contentBase64</code>. The local byte limit defaults to 25 MiB.
|
|
113
|
+
</p>
|
|
114
|
+
<p>
|
|
115
|
+
Emits the artifact metadata on <code>msg.payload</code> and
|
|
116
|
+
<code>msg.artifact</code>.
|
|
117
|
+
</p>
|
|
118
|
+
</script>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { t as withAgent } from "./agent-call.js";
|
|
2
|
+
import { t as bool } from "./query-utils.js";
|
|
3
|
+
import { a as requireAttemptContext, c as resolveUploadBody, o as resolveField, s as resolveMaxBytes } from "./task-artifact-utils.js";
|
|
4
|
+
//#region src/nodes/task-artifact-upload.ts
|
|
5
|
+
var init = (RED) => {
|
|
6
|
+
function TaskArtifactUploadNode(def) {
|
|
7
|
+
RED.nodes.createNode(this, def);
|
|
8
|
+
const agentNode = def.agent ? RED.nodes.getNode(def.agent) : null;
|
|
9
|
+
this.on("input", (msg, send, done) => {
|
|
10
|
+
const run = async () => {
|
|
11
|
+
try {
|
|
12
|
+
if (!agentNode || typeof agentNode.getAgent !== "function") throw new Error("task-artifact-upload: no moltnet-agent configured");
|
|
13
|
+
const { taskId, teamId, attemptN } = requireAttemptContext("task-artifact-upload", msg, def.taskId, def.teamId, def.attemptN, agentNode, bool(def.allowMsgTeamOverride) ?? false);
|
|
14
|
+
const body = resolveUploadBody(msg, resolveMaxBytes(def.maxBytes));
|
|
15
|
+
const query = buildUploadQuery(def, msg);
|
|
16
|
+
this.status({
|
|
17
|
+
fill: "blue",
|
|
18
|
+
shape: "dot",
|
|
19
|
+
text: "uploading…"
|
|
20
|
+
});
|
|
21
|
+
const artifact = await withAgent(agentNode, (agent) => agent.tasks.artifacts.upload({
|
|
22
|
+
taskId,
|
|
23
|
+
attemptN
|
|
24
|
+
}, body, query, { teamId }));
|
|
25
|
+
const out = RED.util.cloneMessage({
|
|
26
|
+
...msg,
|
|
27
|
+
payload: void 0
|
|
28
|
+
});
|
|
29
|
+
out.payload = artifact;
|
|
30
|
+
out.taskId = taskId;
|
|
31
|
+
out.artifact = artifact;
|
|
32
|
+
this.status({
|
|
33
|
+
fill: "green",
|
|
34
|
+
shape: "dot",
|
|
35
|
+
text: artifact.cid
|
|
36
|
+
});
|
|
37
|
+
send(out);
|
|
38
|
+
done();
|
|
39
|
+
} catch (err) {
|
|
40
|
+
this.status({
|
|
41
|
+
fill: "red",
|
|
42
|
+
shape: "ring",
|
|
43
|
+
text: "error"
|
|
44
|
+
});
|
|
45
|
+
done(err instanceof Error ? err : new Error(String(err)));
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
run();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
RED.nodes.registerType("moltnet-task-artifact-upload", TaskArtifactUploadNode);
|
|
52
|
+
};
|
|
53
|
+
function buildUploadQuery(def, msg) {
|
|
54
|
+
return {
|
|
55
|
+
kind: resolveField(msg, "kind", def.kind) ?? "output",
|
|
56
|
+
title: resolveField(msg, "title", def.title) ?? "artifact",
|
|
57
|
+
contentType: resolveField(msg, "contentType", def.contentType) ?? "application/octet-stream",
|
|
58
|
+
contentEncoding: resolveField(msg, "contentEncoding", def.contentEncoding)
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
export { init as default };
|