@homespunapps/cli 1.6.52 → 1.6.54

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.
@@ -0,0 +1,351 @@
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
+ : await 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
+ /**
242
+ * Exported for testing. This function is the one part of `work` that had NO test and
243
+ * shipped broken twice over (a URL built from the app id rather than its real
244
+ * location, and a swallowed connect error), so it is worth being able to point a test
245
+ * straight at it rather than only at the command that calls it.
246
+ */
247
+ export async function openWakeSocket(opts, apiKey, base, onWake) {
248
+ if (opts.appIds.length !== 1)
249
+ return null;
250
+ const appId = opts.appIds[0];
251
+ // ASK THE RELAY where the app lives. This was reconstructed as `${base}/a/${appId}/`,
252
+ // which is wrong in two ways at once: an app is served under its SLUG, not its id,
253
+ // and on a usercontent deployment it is a different origin entirely
254
+ // (`<slug>.homespunapps.com`) rather than a path on the API host. The socket
255
+ // therefore never connected, and the reconnect logic below reported it as
256
+ // "wake socket lost", which reads like a transient blip rather than a URL that
257
+ // could never work. Found by running a worker against a live relay; no test
258
+ // covered this function at all.
259
+ //
260
+ // A failure here is NOT fatal: polling still drains the queue, so a worker that
261
+ // cannot resolve its app keeps working, just without the wake hint.
262
+ let appUrl;
263
+ try {
264
+ const res = await fetch(`${base}/v1/apps/${appId}`, {
265
+ headers: { authorization: `Bearer ${apiKey}` },
266
+ });
267
+ if (!res.ok) {
268
+ warn(`cannot resolve app ${appId} for the wake socket (${res.status}); polling only`);
269
+ return null;
270
+ }
271
+ appUrl = (await res.json()).url ?? "";
272
+ if (!appUrl) {
273
+ warn(`app ${appId} reports no url; polling only`);
274
+ return null;
275
+ }
276
+ }
277
+ catch (err) {
278
+ warn(`cannot resolve app ${appId} for the wake socket (${err instanceof Error ? err.message : String(err)}); polling only`);
279
+ return null;
280
+ }
281
+ let closed = false;
282
+ let connectedOnce = false;
283
+ let delay = RECONNECT_MIN_MS;
284
+ let handle = null;
285
+ let announcedOutage = false;
286
+ const connect = () => {
287
+ if (closed)
288
+ return;
289
+ const wsUrl = appWsUrlFromAppUrl(appUrl);
290
+ handle = openAppStream(
291
+ // `since: 0`, not a huge sentinel. The intent was "do not replay history", and
292
+ // MAX_SAFE_INTEGER expressed that by sending a cursor the relay will not accept,
293
+ // so the socket was refused and the wake frame never arrived. The worker sets no
294
+ // `onEntry` handler at all, so a replayed batch is discarded as it is parsed:
295
+ // there is nothing to avoid.
296
+ { wsUrl, apiKey, since: 0 }, {
297
+ onHello: () => {
298
+ // A successful connect resets the backoff, so a flapping link does not
299
+ // inherit the previous outage's delay.
300
+ connectedOnce = true;
301
+ delay = RECONNECT_MIN_MS;
302
+ if (announcedOutage) {
303
+ warn("wake socket reconnected");
304
+ announcedOutage = false;
305
+ }
306
+ },
307
+ onAgentTaskAvailable: onWake,
308
+ onClose: ({ code, reason }) => scheduleReconnect(`closed ${code}${reason ? ": " + reason : ""}`),
309
+ // The error is REPORTED, not swallowed. Discarding it is what made the
310
+ // original URL bug take three guesses to find: every failure looked
311
+ // identical from the outside.
312
+ onError: (err) => scheduleReconnect(err instanceof Error ? err.message : String(err)),
313
+ });
314
+ };
315
+ const scheduleReconnect = (why) => {
316
+ if (closed)
317
+ return;
318
+ if (!announcedOutage) {
319
+ warn((connectedOnce
320
+ ? "wake socket lost; polling continues while it reconnects"
321
+ : "wake socket could not connect; polling only until it does") +
322
+ ` (${why})`);
323
+ announcedOutage = true;
324
+ }
325
+ const wait = delay;
326
+ delay = Math.min(delay * 2, RECONNECT_MAX_MS);
327
+ setTimeout(connect, wait).unref?.();
328
+ };
329
+ connect();
330
+ return {
331
+ close: () => {
332
+ closed = true;
333
+ handle?.close();
334
+ },
335
+ };
336
+ }
337
+ function positiveInt(raw, fallback) {
338
+ if (raw === undefined)
339
+ return fallback;
340
+ const n = Number(raw);
341
+ if (!Number.isInteger(n) || n <= 0) {
342
+ fail(`expected a positive integer, got '${raw}'`, "invalid_request");
343
+ }
344
+ return n;
345
+ }
346
+ /** Cap the report at what the ack route accepts, keeping the TAIL of a long one:
347
+ * the end of a stack trace says more about a failure than its beginning. */
348
+ function trim(s) {
349
+ const t = s.trim();
350
+ return t.length > 3900 ? t.slice(-3900) : t;
351
+ }
@@ -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.52",
3
+ "version": "1.6.54",
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.52",
39
+ "@homespunapps/core": "^1.6.54",
40
40
  "qrcode-terminal": "^0.12.0"
41
41
  },
42
42
  "devDependencies": {