@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.
@@ -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
+ }
@@ -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.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.52",
39
+ "@homespunapps/core": "^1.6.53",
40
40
  "qrcode-terminal": "^0.12.0"
41
41
  },
42
42
  "devDependencies": {