@homespunapps/cli 1.6.52 → 1.6.53
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/commands/work.js +301 -0
- package/dist/help-catalog.js +46 -0
- package/dist/index.js +4 -0
- package/package.json +2 -2
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// `homespun work` - the long-running worker that drains this identity's agent-task
|
|
2
|
+
// queue and hands each task to whatever agent its owner uses.
|
|
3
|
+
//
|
|
4
|
+
// THE ENVELOPE GOES TO A CHILD PROCESS ON STDIN, and that is the entire integration
|
|
5
|
+
// contract. No SDK, no library, no assumption that the consumer is Claude or that it
|
|
6
|
+
// can call tools: `--exec` names any command, and a shell script reading stdin is a
|
|
7
|
+
// first-class consumer. That is also how the harness-agnostic claim gets tested, by
|
|
8
|
+
// pointing `--exec` at a script rather than at a model.
|
|
9
|
+
//
|
|
10
|
+
// EXIT CODE IS THE ANSWER. Zero acks, non-zero nacks with the child's stderr as the
|
|
11
|
+
// report. Nothing is parsed out of stdout, deliberately: requiring a structured
|
|
12
|
+
// reply would mean every worker needs a wrapper that produces it, and the one thing
|
|
13
|
+
// every program on every platform already reports reliably is its exit status.
|
|
14
|
+
//
|
|
15
|
+
// POLLING IS THE FLOOR. The wake frame only shortens the wait, so this drains
|
|
16
|
+
// correctly with no socket at all. That is why the reconnect logic below is allowed
|
|
17
|
+
// to give up on the socket and keep working.
|
|
18
|
+
//
|
|
19
|
+
// WHY THIS FILE CONTAINS RECONNECT LOGIC AT ALL, when `apps watch` does not: nothing
|
|
20
|
+
// in this CLI has it. `apps watch` falls back to HTTP long-polling permanently on any
|
|
21
|
+
// pre-connect WS failure, never retries, handles SIGINT but not SIGTERM, and parks on
|
|
22
|
+
// `await new Promise(() => {})`. That is fine for a person watching a terminal and
|
|
23
|
+
// wrong for a process meant to run under a supervisor for weeks: it would silently
|
|
24
|
+
// degrade to a slower path and nothing would say so. So this reconnects with capped
|
|
25
|
+
// backoff, says so on stderr when it does, and exits cleanly on SIGTERM.
|
|
26
|
+
import { spawn } from "node:child_process";
|
|
27
|
+
import { openAppStream, appWsUrlFromAppUrl } from "@homespunapps/core";
|
|
28
|
+
import { assertKnownFlags } from "../argv.js";
|
|
29
|
+
import { nounSpec, renderNounHelp, specFor } from "../help-catalog.js";
|
|
30
|
+
import { resolveConfig } from "../config.js";
|
|
31
|
+
import { fail, printJsonLine, warn } from "../output.js";
|
|
32
|
+
/** Backoff bounds for the wake socket. Capped so a long outage does not spin. */
|
|
33
|
+
const RECONNECT_MIN_MS = 1_000;
|
|
34
|
+
const RECONNECT_MAX_MS = 60_000;
|
|
35
|
+
export async function runWork(args) {
|
|
36
|
+
if (args.flags.has("help") ||
|
|
37
|
+
args.bools.has("help") ||
|
|
38
|
+
args.positionals[0] === "help") {
|
|
39
|
+
const spec = nounSpec("work");
|
|
40
|
+
if (spec)
|
|
41
|
+
process.stdout.write(renderNounHelp(spec));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
// The catalogue entry IS the flag allowlist, so a flag documented in help and a
|
|
45
|
+
// flag accepted here cannot drift apart.
|
|
46
|
+
assertKnownFlags(args, ...specFor("work"));
|
|
47
|
+
const exec = args.flags.get("exec");
|
|
48
|
+
if (!exec) {
|
|
49
|
+
fail("work requires --exec <command>: the program each task envelope is piped to", "invalid_request");
|
|
50
|
+
}
|
|
51
|
+
const opts = {
|
|
52
|
+
// Repeatable OR comma-separated, because both are things a person types.
|
|
53
|
+
appIds: (args.flags.get("app") ?? "")
|
|
54
|
+
.split(",")
|
|
55
|
+
.map((s) => s.trim())
|
|
56
|
+
.filter(Boolean),
|
|
57
|
+
exec: exec,
|
|
58
|
+
maxConcurrent: positiveInt(args.flags.get("max-concurrent"), 1),
|
|
59
|
+
once: args.bools.has("once") || args.flags.has("once"),
|
|
60
|
+
pollSeconds: positiveInt(args.flags.get("poll-interval"), 15),
|
|
61
|
+
};
|
|
62
|
+
const cfg = resolveConfig(args);
|
|
63
|
+
const base = cfg.url.replace(/\/$/, "");
|
|
64
|
+
let stopping = false;
|
|
65
|
+
/** Resolves early when the wake frame arrives, so a sleep can be interrupted. */
|
|
66
|
+
let wake = null;
|
|
67
|
+
const stop = () => {
|
|
68
|
+
stopping = true;
|
|
69
|
+
wake?.();
|
|
70
|
+
};
|
|
71
|
+
// BOTH signals. A supervisor sends SIGTERM, and a worker that only handles SIGINT
|
|
72
|
+
// gets killed mid-task, which strands its lease until it expires.
|
|
73
|
+
//
|
|
74
|
+
// Removed again in the `finally` below. In production this runs once and process
|
|
75
|
+
// exit would clean up anyway, but leaving them attached leaks a listener per call,
|
|
76
|
+
// which surfaced as a MaxListenersExceededWarning once the test suite called this
|
|
77
|
+
// fifteen times in one process. A warning that only appears under test is still a
|
|
78
|
+
// handler this function attached and did not own up to.
|
|
79
|
+
process.on("SIGINT", stop);
|
|
80
|
+
process.on("SIGTERM", stop);
|
|
81
|
+
const socket = opts.once
|
|
82
|
+
? null
|
|
83
|
+
: openWakeSocket(opts, cfg.apiKey, base, () => wake?.());
|
|
84
|
+
try {
|
|
85
|
+
for (;;) {
|
|
86
|
+
const claimed = await claim(base, cfg.apiKey, opts);
|
|
87
|
+
for (const task of claimed) {
|
|
88
|
+
await runTask(base, cfg.apiKey, task, opts.exec);
|
|
89
|
+
if (stopping)
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
if (opts.once || stopping)
|
|
93
|
+
break;
|
|
94
|
+
// Sleep, interruptible by the wake frame. `wake` is re-armed each pass so a
|
|
95
|
+
// frame that arrives WHILE tasks are running does not resolve a stale promise
|
|
96
|
+
// and get lost; the next sleep is what it shortens.
|
|
97
|
+
await new Promise((resolve) => {
|
|
98
|
+
const timer = setTimeout(resolve, opts.pollSeconds * 1000);
|
|
99
|
+
wake = () => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
resolve();
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
wake = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
socket?.close();
|
|
109
|
+
process.off("SIGINT", stop);
|
|
110
|
+
process.off("SIGTERM", stop);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Claim a batch. A claim failure is NOT fatal outside `--once`: the relay may be
|
|
115
|
+
* restarting or briefly unreachable, and a worker that exits on the first 503 is a
|
|
116
|
+
* worker that needs a supervisor to do its retrying. Logged and retried on the next
|
|
117
|
+
* pass instead.
|
|
118
|
+
*/
|
|
119
|
+
async function claim(base, apiKey, opts) {
|
|
120
|
+
const body = { max: opts.maxConcurrent };
|
|
121
|
+
if (opts.appIds.length > 0)
|
|
122
|
+
body.app_ids = opts.appIds;
|
|
123
|
+
try {
|
|
124
|
+
const res = await fetch(`${base}/v1/agent-tasks/claim`, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: {
|
|
127
|
+
authorization: `Bearer ${apiKey}`,
|
|
128
|
+
"content-type": "application/json",
|
|
129
|
+
},
|
|
130
|
+
body: JSON.stringify(body),
|
|
131
|
+
});
|
|
132
|
+
if (!res.ok) {
|
|
133
|
+
const text = await res.text();
|
|
134
|
+
if (opts.once) {
|
|
135
|
+
fail(`claim failed (${res.status}): ${text}`, "claim_failed");
|
|
136
|
+
}
|
|
137
|
+
warn(`claim failed (${res.status}), retrying next pass: ${text}`);
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
return (await res.json()).tasks ?? [];
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
144
|
+
if (opts.once)
|
|
145
|
+
fail(`claim failed: ${msg}`, "claim_failed");
|
|
146
|
+
warn(`claim failed, retrying next pass: ${msg}`);
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Hand one task to the child and report the outcome.
|
|
152
|
+
*
|
|
153
|
+
* The whole envelope goes on stdin as one JSON line, including the credential, so a
|
|
154
|
+
* worker needs no configuration of its own to write results back: everything it
|
|
155
|
+
* needs to act is in the thing it was handed.
|
|
156
|
+
*/
|
|
157
|
+
async function runTask(base, apiKey, task, exec) {
|
|
158
|
+
const started = Date.now();
|
|
159
|
+
const result = await runChild(exec, JSON.stringify(task));
|
|
160
|
+
const seconds = Math.round((Date.now() - started) / 100) / 10;
|
|
161
|
+
if (result.code === 0) {
|
|
162
|
+
await report(base, apiKey, task.task_id, "ack", trim(result.stdout));
|
|
163
|
+
printJsonLine({
|
|
164
|
+
task: task.task_id,
|
|
165
|
+
app: task.app_slug,
|
|
166
|
+
type: task.task_type,
|
|
167
|
+
status: "done",
|
|
168
|
+
seconds,
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// Non-zero: nack, with the child's STDERR as the report. Stderr rather than stdout
|
|
173
|
+
// because that is where a failing program explains itself, and the report is read
|
|
174
|
+
// by a person working out why their task did not run.
|
|
175
|
+
await report(base, apiKey, task.task_id, "nack", trim(result.stderr || result.stdout) ||
|
|
176
|
+
`worker exited ${result.code ?? "on a signal"}`);
|
|
177
|
+
printJsonLine({
|
|
178
|
+
task: task.task_id,
|
|
179
|
+
app: task.app_slug,
|
|
180
|
+
type: task.task_type,
|
|
181
|
+
status: "failed",
|
|
182
|
+
exit: result.code,
|
|
183
|
+
seconds,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
function runChild(exec, stdin) {
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
// Through a shell, so `--exec "claude -p"` and `--exec ./parse.sh` both work the
|
|
189
|
+
// way a person expects when they type them.
|
|
190
|
+
const child = spawn(exec, { shell: true });
|
|
191
|
+
let stdout = "";
|
|
192
|
+
let stderr = "";
|
|
193
|
+
child.stdout.on("data", (d) => (stdout += String(d)));
|
|
194
|
+
child.stderr.on("data", (d) => (stderr += String(d)));
|
|
195
|
+
child.on("error", (err) => {
|
|
196
|
+
resolve({ code: 127, stdout, stderr: stderr + String(err) });
|
|
197
|
+
});
|
|
198
|
+
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
199
|
+
child.stdin.write(stdin);
|
|
200
|
+
child.stdin.end();
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Ack or nack. A REPORTING failure is logged and swallowed rather than thrown: the
|
|
205
|
+
* work has already happened, and the lease will lapse and return the task to the
|
|
206
|
+
* queue on its own, which is the correct recovery. Crashing the worker here would
|
|
207
|
+
* lose every other task it holds for the sake of one it could not report on.
|
|
208
|
+
*/
|
|
209
|
+
async function report(base, apiKey, taskId, verb, text) {
|
|
210
|
+
try {
|
|
211
|
+
const res = await fetch(`${base}/v1/agent-tasks/${taskId}/${verb}`, {
|
|
212
|
+
method: "POST",
|
|
213
|
+
headers: {
|
|
214
|
+
authorization: `Bearer ${apiKey}`,
|
|
215
|
+
"content-type": "application/json",
|
|
216
|
+
},
|
|
217
|
+
body: JSON.stringify(text ? { report: text } : {}),
|
|
218
|
+
});
|
|
219
|
+
if (!res.ok) {
|
|
220
|
+
warn(`${verb} failed for ${taskId} (${res.status}); lease will lapse`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
warn(`${verb} failed for ${taskId} (${err instanceof Error ? err.message : String(err)}); lease will lapse`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* The wake socket, with real reconnect.
|
|
229
|
+
*
|
|
230
|
+
* ONE APP ONLY, and this is the honest limitation of the frame rather than of this
|
|
231
|
+
* command: the hint is published on the app's own `/_hs/ws`, so a worker draining
|
|
232
|
+
* five apps would need five sockets. Polling already drains every app correctly, so
|
|
233
|
+
* the socket is opened only when `--app` names exactly one and is simply skipped
|
|
234
|
+
* otherwise. A multi-app worker polls, which costs latency and nothing else.
|
|
235
|
+
*
|
|
236
|
+
* Reconnects with capped exponential backoff and says so, once, per outage. It never
|
|
237
|
+
* escalates to an exit: losing the socket makes this slower, not broken, and a
|
|
238
|
+
* worker that killed itself over a lost optimisation would be worse than one that
|
|
239
|
+
* kept polling.
|
|
240
|
+
*/
|
|
241
|
+
function openWakeSocket(opts, apiKey, base, onWake) {
|
|
242
|
+
if (opts.appIds.length !== 1)
|
|
243
|
+
return null;
|
|
244
|
+
const appId = opts.appIds[0];
|
|
245
|
+
let closed = false;
|
|
246
|
+
let delay = RECONNECT_MIN_MS;
|
|
247
|
+
let handle = null;
|
|
248
|
+
let announcedOutage = false;
|
|
249
|
+
const connect = () => {
|
|
250
|
+
if (closed)
|
|
251
|
+
return;
|
|
252
|
+
const wsUrl = appWsUrlFromAppUrl(`${base}/a/${appId}/`);
|
|
253
|
+
handle = openAppStream({ wsUrl, apiKey, since: Number.MAX_SAFE_INTEGER }, {
|
|
254
|
+
onHello: () => {
|
|
255
|
+
// A successful connect resets the backoff, so a flapping link does not
|
|
256
|
+
// inherit the previous outage's delay.
|
|
257
|
+
delay = RECONNECT_MIN_MS;
|
|
258
|
+
if (announcedOutage) {
|
|
259
|
+
warn("wake socket reconnected");
|
|
260
|
+
announcedOutage = false;
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
onAgentTaskAvailable: onWake,
|
|
264
|
+
onClose: () => scheduleReconnect(),
|
|
265
|
+
onError: () => scheduleReconnect(),
|
|
266
|
+
});
|
|
267
|
+
};
|
|
268
|
+
const scheduleReconnect = () => {
|
|
269
|
+
if (closed)
|
|
270
|
+
return;
|
|
271
|
+
if (!announcedOutage) {
|
|
272
|
+
warn("wake socket lost; polling continues while it reconnects");
|
|
273
|
+
announcedOutage = true;
|
|
274
|
+
}
|
|
275
|
+
const wait = delay;
|
|
276
|
+
delay = Math.min(delay * 2, RECONNECT_MAX_MS);
|
|
277
|
+
setTimeout(connect, wait).unref?.();
|
|
278
|
+
};
|
|
279
|
+
connect();
|
|
280
|
+
return {
|
|
281
|
+
close: () => {
|
|
282
|
+
closed = true;
|
|
283
|
+
handle?.close();
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function positiveInt(raw, fallback) {
|
|
288
|
+
if (raw === undefined)
|
|
289
|
+
return fallback;
|
|
290
|
+
const n = Number(raw);
|
|
291
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
292
|
+
fail(`expected a positive integer, got '${raw}'`, "invalid_request");
|
|
293
|
+
}
|
|
294
|
+
return n;
|
|
295
|
+
}
|
|
296
|
+
/** Cap the report at what the ack route accepts, keeping the TAIL of a long one:
|
|
297
|
+
* the end of a stack trace says more about a failure than its beginning. */
|
|
298
|
+
function trim(s) {
|
|
299
|
+
const t = s.trim();
|
|
300
|
+
return t.length > 3900 ? t.slice(-3900) : t;
|
|
301
|
+
}
|
package/dist/help-catalog.js
CHANGED
|
@@ -1595,9 +1595,55 @@ const REVIEW = {
|
|
|
1595
1595
|
};
|
|
1596
1596
|
// Order here is the order in `homespun --help` and in the generated reference
|
|
1597
1597
|
// page: app commands first, then the rest.
|
|
1598
|
+
const WORK = {
|
|
1599
|
+
noun: "work",
|
|
1600
|
+
tagline: "run the agent-task worker",
|
|
1601
|
+
group: "app",
|
|
1602
|
+
rootSummary: "Runs a long-lived worker that claims agent tasks for your apps and pipes each one to a command you name.",
|
|
1603
|
+
verbs: [
|
|
1604
|
+
{
|
|
1605
|
+
verb: "",
|
|
1606
|
+
summary: "Claims tasks and pipes each envelope to --exec on stdin, acking on exit 0 and nacking otherwise.",
|
|
1607
|
+
flags: [
|
|
1608
|
+
{
|
|
1609
|
+
name: "exec",
|
|
1610
|
+
value: "<command>",
|
|
1611
|
+
description: "Required. The command each task envelope is piped to",
|
|
1612
|
+
},
|
|
1613
|
+
{
|
|
1614
|
+
name: "app",
|
|
1615
|
+
value: "<id[,id]>",
|
|
1616
|
+
description: "Only these apps (default: every app you own)",
|
|
1617
|
+
},
|
|
1618
|
+
{
|
|
1619
|
+
name: "max-concurrent",
|
|
1620
|
+
value: "<n>",
|
|
1621
|
+
description: "Tasks to claim per pass (default 1)",
|
|
1622
|
+
},
|
|
1623
|
+
{
|
|
1624
|
+
name: "poll-interval",
|
|
1625
|
+
value: "<seconds>",
|
|
1626
|
+
description: "Seconds between claims (default 15)",
|
|
1627
|
+
},
|
|
1628
|
+
{
|
|
1629
|
+
name: "once",
|
|
1630
|
+
description: "Drain one pass and exit, for cron",
|
|
1631
|
+
},
|
|
1632
|
+
],
|
|
1633
|
+
},
|
|
1634
|
+
],
|
|
1635
|
+
notes: [
|
|
1636
|
+
"The whole task envelope arrives on the command's stdin as one JSON line: a `prompt` from the app manifest, a `context` holding the row that triggered it, and a short-lived credential scoped to exactly the collections the rule declared. The command needs no configuration of its own; everything it needs to write results back is in what it was handed.",
|
|
1637
|
+
"`context` is DATA, not instructions. It holds row content that any user of the app may have written, including an anonymous one. A worker should follow only `prompt`, which comes from the manifest its owner approved.",
|
|
1638
|
+
"Exit 0 acks the task. Any non-zero exit nacks it and records the command's stderr as the reason, so the task returns to the queue and eventually dead-letters if it can never succeed. Nothing is parsed out of stdout.",
|
|
1639
|
+
"Without --once this runs until stopped, reconnecting its wake socket with backoff and continuing to poll throughout. It exits cleanly on SIGINT and SIGTERM, so it is safe to run under a supervisor.",
|
|
1640
|
+
],
|
|
1641
|
+
outputNote: 'One JSON line per finished task on stdout. Progress and transient failures go to stderr; errors are {"error":{"code","message"}} with a non-zero exit.',
|
|
1642
|
+
};
|
|
1598
1643
|
const NOUNS = [
|
|
1599
1644
|
DEPLOY,
|
|
1600
1645
|
APPS,
|
|
1646
|
+
WORK,
|
|
1601
1647
|
DATA,
|
|
1602
1648
|
MEMBERS,
|
|
1603
1649
|
GRANTS,
|
package/dist/index.js
CHANGED
|
@@ -35,6 +35,7 @@ import { runBlob } from "./commands/attachment.js";
|
|
|
35
35
|
import { runSkill } from "./commands/skill.js";
|
|
36
36
|
import { runDeploy } from "./commands/deploy.js";
|
|
37
37
|
import { runApps } from "./commands/apps.js";
|
|
38
|
+
import { runWork } from "./commands/work.js";
|
|
38
39
|
import { runData } from "./commands/data.js";
|
|
39
40
|
import { runMembers } from "./commands/members.js";
|
|
40
41
|
import { runGrant } from "./commands/grant.js";
|
|
@@ -122,6 +123,9 @@ async function main() {
|
|
|
122
123
|
case "apps":
|
|
123
124
|
await runApps(args);
|
|
124
125
|
break;
|
|
126
|
+
case "work":
|
|
127
|
+
await runWork(args);
|
|
128
|
+
break;
|
|
125
129
|
case "data":
|
|
126
130
|
await runData(args);
|
|
127
131
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homespunapps/cli",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.53",
|
|
4
4
|
"description": "Command-line client for the Homespun relay: deploy a real multi-user web app from your agent, then keep reading and writing its data.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"test:unit": "vitest run"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@homespunapps/core": "^1.6.
|
|
39
|
+
"@homespunapps/core": "^1.6.53",
|
|
40
40
|
"qrcode-terminal": "^0.12.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|