@north-light/crouter 0.3.299 → 0.3.300
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.
|
@@ -1,14 +1,99 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import {
|
|
3
|
+
import { PassThrough } from 'node:stream';
|
|
4
|
+
import { parseArgv } from '../../core/command.js';
|
|
5
|
+
import { renderBranch, renderLeafArgv } from '../../core/help.js';
|
|
4
6
|
import { nodeMessage } from '../node/message.js';
|
|
7
|
+
function messageLeaf(name) {
|
|
8
|
+
const leaf = nodeMessage.children.find((child) => child.kind === 'leaf' && child.name === name);
|
|
9
|
+
if (leaf === undefined || leaf.kind !== 'leaf')
|
|
10
|
+
assert.fail(`node message ${name} leaf is missing`);
|
|
11
|
+
return leaf;
|
|
12
|
+
}
|
|
13
|
+
async function withFakeStdin(fake, fn) {
|
|
14
|
+
const real = process.stdin;
|
|
15
|
+
Object.defineProperty(process, 'stdin', { value: fake, configurable: true });
|
|
16
|
+
try {
|
|
17
|
+
return await fn();
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
Object.defineProperty(process, 'stdin', { value: real, configurable: true });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function pipedStdin(body) {
|
|
24
|
+
const stdin = new PassThrough();
|
|
25
|
+
stdin.end(body);
|
|
26
|
+
return stdin;
|
|
27
|
+
}
|
|
28
|
+
test('node message help names the target flag in the family and both child rubrics', () => {
|
|
29
|
+
const rendered = renderBranch(nodeMessage.help);
|
|
30
|
+
assert.match(rendered, /targeted with `--to <node-id>` or `--self`/);
|
|
31
|
+
assert.match(rendered, /send[\s\S]*?whenToUse="a target chosen with `--to <node-id>` or `--self` should have information/);
|
|
32
|
+
assert.match(rendered, /request[\s\S]*?whenToUse="a target chosen with `--to <node-id>` or `--self` must hand back one answer/);
|
|
33
|
+
});
|
|
5
34
|
test('node message send help documents the reopen re-task semantics', () => {
|
|
6
|
-
const
|
|
7
|
-
if (send === undefined || send.kind !== 'leaf')
|
|
8
|
-
assert.fail('node message send leaf is missing');
|
|
9
|
-
const rendered = renderLeafArgv(send.help);
|
|
35
|
+
const rendered = renderLeafArgv(messageLeaf('send').help);
|
|
10
36
|
assert.match(rendered, /--reopen/);
|
|
11
37
|
assert.match(rendered, /Re-task the target: commits it resident and clears any finalization latch before delivery/);
|
|
12
38
|
assert.match(rendered, /valid for live, parked, and finalized targets/);
|
|
13
39
|
assert.match(rendered, /Without it, a finalized target rejects delivery before an inbox entry is appended or a revive is attempted/);
|
|
14
40
|
});
|
|
41
|
+
test('node message send explains a positional node id without a target flag', async () => {
|
|
42
|
+
const send = messageLeaf('send');
|
|
43
|
+
const nodeId = '3zl47w7d-mtqb57ts-74ea25fe';
|
|
44
|
+
await assert.rejects(() => send.run({ body: nodeId }), (error) => {
|
|
45
|
+
assert.equal(error.code, 'bad_target');
|
|
46
|
+
assert.equal(error.message, 'no target given — the message body is not a node id');
|
|
47
|
+
assert.equal(error.details?.field, 'to');
|
|
48
|
+
assert.equal(error.details?.next, 'Pass the node id with --to <node-id> and provide the message body as a positional argument or on stdin, or use --self.');
|
|
49
|
+
return true;
|
|
50
|
+
});
|
|
51
|
+
const params = send.help.params;
|
|
52
|
+
if (params === undefined)
|
|
53
|
+
assert.fail('node message send input schema is missing');
|
|
54
|
+
await withFakeStdin(pipedStdin('A long message body.'), () => assert.rejects(() => parseArgv(params, [nodeId]), (error) => {
|
|
55
|
+
assert.equal(error.code, 'bad_invocation');
|
|
56
|
+
assert.equal(error.message, 'no target given — the positional argument is the message body, not a node id');
|
|
57
|
+
assert.equal(error.details?.field, 'to');
|
|
58
|
+
assert.equal(error.details?.next, 'Pass the node id with --to <node-id> and pipe the message body on stdin, or use --self.');
|
|
59
|
+
return true;
|
|
60
|
+
}));
|
|
61
|
+
});
|
|
62
|
+
test('node message request gives the same no-target guidance', async () => {
|
|
63
|
+
const request = messageLeaf('request');
|
|
64
|
+
const nodeId = '3zl47w7d-mtqb57ts-74ea25fe';
|
|
65
|
+
await assert.rejects(() => request.run({ body: nodeId, outputSchema: '{}' }), (error) => {
|
|
66
|
+
assert.equal(error.code, 'bad_target');
|
|
67
|
+
assert.equal(error.message, 'no target given — the message body is not a node id');
|
|
68
|
+
assert.equal(error.details?.field, 'to');
|
|
69
|
+
assert.equal(error.details?.next, 'Pass the node id with --to <node-id> and provide the message body as a positional argument or on stdin, or use --self.');
|
|
70
|
+
return true;
|
|
71
|
+
});
|
|
72
|
+
const params = request.help.params;
|
|
73
|
+
if (params === undefined)
|
|
74
|
+
assert.fail('node message request input schema is missing');
|
|
75
|
+
await withFakeStdin(pipedStdin('A long message body.'), () => assert.rejects(() => parseArgv(params, [nodeId]), (error) => {
|
|
76
|
+
assert.equal(error.code, 'bad_invocation');
|
|
77
|
+
assert.equal(error.message, 'no target given — the positional argument is the message body, not a node id');
|
|
78
|
+
assert.equal(error.details?.field, 'to');
|
|
79
|
+
assert.equal(error.details?.next, 'Pass the node id with --to <node-id> and pipe the message body on stdin, or use --self.');
|
|
80
|
+
return true;
|
|
81
|
+
}));
|
|
82
|
+
});
|
|
83
|
+
test('a targeted node message keeps positional-body parsing and generic stdin conflicts', async () => {
|
|
84
|
+
const send = messageLeaf('send');
|
|
85
|
+
const params = send.help.params;
|
|
86
|
+
if (params === undefined)
|
|
87
|
+
assert.fail('node message send input schema is missing');
|
|
88
|
+
const parsed = await withFakeStdin(pipedStdin(''), () => parseArgv(params, ['message body', '--to', 'target-node']));
|
|
89
|
+
assert.equal(parsed['body'], 'message body');
|
|
90
|
+
assert.equal(parsed['to'], 'target-node');
|
|
91
|
+
const genericParams = [{ kind: 'stdin', name: 'body', required: false, constraint: 'body' }];
|
|
92
|
+
await withFakeStdin(pipedStdin('A long message body.'), () => assert.rejects(() => parseArgv(genericParams, ['message body']), (error) => {
|
|
93
|
+
assert.equal(error.code, 'bad_invocation');
|
|
94
|
+
assert.equal(error.message, 'both a positional argument ("message body") and piped stdin were provided for body');
|
|
95
|
+
assert.equal(error.details?.field, 'body');
|
|
96
|
+
assert.equal(error.details?.next, 'Pass exactly one: either the positional argument or piped stdin, not both.');
|
|
97
|
+
return true;
|
|
98
|
+
}));
|
|
99
|
+
});
|
|
@@ -11,15 +11,16 @@ function requireNonBlankDiscriminant(input, name) {
|
|
|
11
11
|
throw new InputError({ error: 'empty_discriminant', message: `--${flag} must contain non-whitespace text`, received: typeof value === 'string' ? value : String(value), field: flag, next: `Pass a non-empty --${flag} value, or omit the flag.` });
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
-
async function resolveMsgTarget(input) {
|
|
14
|
+
async function resolveMsgTarget(input, hasBody) {
|
|
15
15
|
const to = input['to']?.trim();
|
|
16
16
|
const self = input['self'] === true;
|
|
17
17
|
const hasTo = to !== undefined && to !== '';
|
|
18
18
|
if (hasTo === self) {
|
|
19
19
|
throw new InputError({
|
|
20
20
|
error: 'bad_target',
|
|
21
|
-
message: hasTo && self ? '--to and --self are mutually exclusive' : 'exactly one of --to or --self is required',
|
|
22
|
-
|
|
21
|
+
message: hasTo && self ? '--to and --self are mutually exclusive' : hasBody ? 'no target given — the message body is not a node id' : 'exactly one of --to or --self is required',
|
|
22
|
+
field: hasTo ? undefined : 'to',
|
|
23
|
+
next: hasTo ? 'Pass --to <node-id> or --self.' : hasBody ? 'Pass the node id with --to <node-id> and provide the message body as a positional argument or on stdin, or use --self.' : 'Pass --to <node-id> or --self.',
|
|
23
24
|
});
|
|
24
25
|
}
|
|
25
26
|
if (self) {
|
|
@@ -41,9 +42,19 @@ function messageOutput() {
|
|
|
41
42
|
{ name: 'guidance', type: 'string', required: true, constraint: 'Immediate action confirmation.' },
|
|
42
43
|
];
|
|
43
44
|
}
|
|
45
|
+
function missingMessageTargetForBody(input) {
|
|
46
|
+
const to = input['to']?.trim();
|
|
47
|
+
if ((to !== undefined && to !== '') || input['self'] === true)
|
|
48
|
+
return undefined;
|
|
49
|
+
return {
|
|
50
|
+
message: 'no target given — the positional argument is the message body, not a node id',
|
|
51
|
+
field: 'to',
|
|
52
|
+
next: 'Pass the node id with --to <node-id> and pipe the message body on stdin, or use --self.',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
44
55
|
function messageSendParams() {
|
|
45
56
|
return [
|
|
46
|
-
{ kind: 'stdin', name: 'body', required: false, constraint: 'Visible message body. Required unless --situational-context is supplied.' },
|
|
57
|
+
{ kind: 'stdin', name: 'body', required: false, constraint: 'Visible message body. Required unless --situational-context is supplied.', positionalStdinConflict: missingMessageTargetForBody },
|
|
47
58
|
{ kind: 'flag', name: 'to', type: 'string', required: false, constraint: 'Target an existing node by id. Exactly one of --to or --self is required.' },
|
|
48
59
|
{ kind: 'flag', name: 'self', type: 'bool', required: false, constraint: 'Target the calling node. Exactly one of --to or --self is required.' },
|
|
49
60
|
{ kind: 'flag', name: 'tier', type: 'enum', choices: ['critical', 'urgent', 'normal', 'deferred'], required: false, default: 'normal', constraint: 'Delivery urgency. Deferred never wakes an idle node, except a terminal one, which has no later cycle to read it on and is raised to urgent.' },
|
|
@@ -53,7 +64,7 @@ function messageSendParams() {
|
|
|
53
64
|
}
|
|
54
65
|
function messageRequestParams() {
|
|
55
66
|
return [
|
|
56
|
-
{ kind: 'stdin', name: 'body', required: false, constraint: 'Visible message body. The schema alone is a complete request.' },
|
|
67
|
+
{ kind: 'stdin', name: 'body', required: false, constraint: 'Visible message body. The schema alone is a complete request.', positionalStdinConflict: missingMessageTargetForBody },
|
|
57
68
|
{ kind: 'flag', name: 'to', type: 'string', required: false, constraint: 'Target an existing node by id. Exactly one of --to or --self is required.' },
|
|
58
69
|
{ kind: 'flag', name: 'self', type: 'bool', required: false, constraint: 'Target the calling node. Exactly one of --to or --self is required.' },
|
|
59
70
|
{ kind: 'flag', name: 'tier', type: 'enum', choices: ['critical', 'urgent', 'normal'], required: false, default: 'normal', constraint: 'Delivery urgency. Every accepted tier may revive a dormant target, because the request must be answered; deferred is rejected for that reason.' },
|
|
@@ -68,10 +79,10 @@ async function runMessageEngine(input) {
|
|
|
68
79
|
if (situationalContextSupplied && situationalContextRaw === '') {
|
|
69
80
|
throw new InputError({ error: 'empty_situational_context', message: '--situational-context must contain non-whitespace text', field: 'situational-context', next: 'Pass non-whitespace text, or omit --situational-context.' });
|
|
70
81
|
}
|
|
71
|
-
const { targetId } = await resolveMsgTarget(input);
|
|
72
82
|
const bodyRaw = input['body'];
|
|
73
83
|
const body = bodyRaw !== undefined ? bodyRaw.trim() : undefined;
|
|
74
84
|
const hasBody = body !== undefined && body !== '';
|
|
85
|
+
const { targetId } = await resolveMsgTarget(input, hasBody);
|
|
75
86
|
const tierRaw = input['tier'];
|
|
76
87
|
const hasTier = tierRaw !== undefined && tierRaw !== '';
|
|
77
88
|
const outputSchemaParsed = parseOutputSchemaValue(input['outputSchema']);
|
|
@@ -107,7 +118,7 @@ async function runMessageEngine(input) {
|
|
|
107
118
|
const nodeMessageSend = defineLeaf({
|
|
108
119
|
name: 'send',
|
|
109
120
|
description: 'deliver an inbox message to an existing node immediately',
|
|
110
|
-
whenToUse: 'a node
|
|
121
|
+
whenToUse: 'a target chosen with `--to <node-id>` or `--self` should have information or new direction now — steering a worker mid-task, handing over a finding it needs, correcting a wrong turn, or waking a dormant node with fresh work. The target reads it and decides what to do with it. Use `node message request` instead when you need an answer back in a shape you specify, and `node subscription add` when the target should keep receiving this node’s pushes rather than this one delivery',
|
|
111
122
|
help: {
|
|
112
123
|
name: 'node message send',
|
|
113
124
|
summary: 'deliver an inbox message to an existing node immediately',
|
|
@@ -122,7 +133,7 @@ const nodeMessageSend = defineLeaf({
|
|
|
122
133
|
const nodeMessageRequest = defineLeaf({
|
|
123
134
|
name: 'request',
|
|
124
135
|
description: 'deliver an immediate typed-output request to an existing node',
|
|
125
|
-
whenToUse: 'a node
|
|
136
|
+
whenToUse: 'a target chosen with `--to <node-id>` or `--self` must hand back one answer in a shape you fix — a choice among options, a structured extraction, a verdict your own logic will branch on — or must be held to answering at all before it goes quiet. Use `node message send` instead when prose in the target’s own judgment is enough, and `node new --output-schema` when no node holds the context yet and the work needs a fresh agent',
|
|
126
137
|
help: {
|
|
127
138
|
name: 'node message request',
|
|
128
139
|
summary: 'deliver an immediate typed-output request to an existing node',
|
|
@@ -144,7 +155,7 @@ export const nodeMessage = defineBranch({
|
|
|
144
155
|
help: {
|
|
145
156
|
name: 'node message',
|
|
146
157
|
summary: 'deliver one immediate inbox entry to an existing node',
|
|
147
|
-
model: 'Both children append one inbox entry to a node that already exists, targeted
|
|
158
|
+
model: 'Both children append one inbox entry to a node that already exists, targeted with `--to <node-id>` or `--self`, and may revive it if it is dormant. They differ in what the delivery obligates. An ordinary message obligates nothing: the target reads it and acts however it judges best. A request installs a one-off output schema and holds the target awake and owing until it answers; the answer is validated against the schema, written to the target’s result file, and pushed as an update report to the TARGET’S subscribers, so subscribe to it first if the answer has to reach you. Neither child creates that standing relationship — a durable delivery edge is `node subscription`, and work that needs a fresh agent is `node new`.',
|
|
148
159
|
},
|
|
149
160
|
children: [nodeMessageSend, nodeMessageRequest],
|
|
150
161
|
});
|
package/dist/core/command.js
CHANGED
|
@@ -503,7 +503,8 @@ export async function parseArgv(params, tokens, options) {
|
|
|
503
503
|
// whole invocation waiting for a close that will never come.
|
|
504
504
|
const piped = await peekStdinRaw();
|
|
505
505
|
if (piped.trim() !== '') {
|
|
506
|
-
|
|
506
|
+
const conflict = stdinParam.positionalStdinConflict?.(result);
|
|
507
|
+
throw parseArgvError('bad_invocation', conflict?.message ?? `both a positional argument ("${positionalValue}") and piped stdin were provided for ${stdinParam.name}`, positionalValue, conflict?.field ?? stdinParam.name, conflict?.next ?? `Pass exactly one: either the positional argument or piped stdin, not both.`);
|
|
507
508
|
}
|
|
508
509
|
result[flagNameToKey(stdinParam.name)] = positionalValue;
|
|
509
510
|
stdinWasSupplied = true; // positional supplying stdin counts as supplied
|
package/dist/core/help.d.ts
CHANGED
|
@@ -87,6 +87,13 @@ export interface StdinParam {
|
|
|
87
87
|
/** Whether one positional token may supply stdin instead of a pipe. Defaults
|
|
88
88
|
* to true for existing stdin-body leaves; false requires actual stdin. */
|
|
89
89
|
allowPositional?: boolean;
|
|
90
|
+
/** Optional leaf-specific recovery when a positional stdin body collides with
|
|
91
|
+
* piped stdin. Return undefined to keep the generic collision error. */
|
|
92
|
+
positionalStdinConflict?: (input: Readonly<Record<string, unknown>>) => {
|
|
93
|
+
message: string;
|
|
94
|
+
field?: string;
|
|
95
|
+
next: string;
|
|
96
|
+
} | undefined;
|
|
90
97
|
}
|
|
91
98
|
/** --context-file PATH: reads and JSON-parses the file at PATH. */
|
|
92
99
|
export interface ContextFileParam {
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.300",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.300",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"workspaces": [
|
|
@@ -5206,17 +5206,17 @@
|
|
|
5206
5206
|
},
|
|
5207
5207
|
"packages/crouter-api": {
|
|
5208
5208
|
"name": "@north-light/crouter-api",
|
|
5209
|
-
"version": "0.3.
|
|
5209
|
+
"version": "0.3.300",
|
|
5210
5210
|
"license": "UNLICENSED"
|
|
5211
5211
|
},
|
|
5212
5212
|
"packages/crouter-env-docker": {
|
|
5213
5213
|
"name": "@north-light/crouter-env-docker",
|
|
5214
|
-
"version": "0.3.
|
|
5214
|
+
"version": "0.3.300",
|
|
5215
5215
|
"license": "UNLICENSED"
|
|
5216
5216
|
},
|
|
5217
5217
|
"packages/crouter-sdk": {
|
|
5218
5218
|
"name": "@north-light/crouter-sdk",
|
|
5219
|
-
"version": "0.3.
|
|
5219
|
+
"version": "0.3.300",
|
|
5220
5220
|
"license": "UNLICENSED",
|
|
5221
5221
|
"dependencies": {
|
|
5222
5222
|
"@north-light/crouter-api": "^0.3.295"
|