storyforge 0.5.3 → 0.6.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.
@@ -9,6 +9,7 @@ import * as crypto from "crypto";
9
9
  import * as fs from "fs";
10
10
  var HEARTBEAT_INTERVAL_MS = 6e4;
11
11
  var POLL_INTERVAL_MS = 8e3;
12
+ var CANCEL_POLL_INTERVAL_MS = 5e3;
12
13
  var CLI_TIMEOUT_MS = Number(process.env.SCRIPT_GEN_TIMEOUT_MS) || 20 * 60 * 1e3;
13
14
  var IMAGE_GEN_TIMEOUT_MS = Number(process.env.CODEX_IMAGE_GEN_TIMEOUT_MS) || Number(process.env.IMAGE_GEN_TIMEOUT_MS) || 8 * 60 * 1e3;
14
15
  var MAX_IN_FLIGHT_JOBS = Math.max(1, Math.min(6, Number(process.env.FORGE_BRIDGE_MAX_IN_FLIGHT) || 4));
@@ -21,7 +22,14 @@ var BridgePoller = class {
21
22
  instanceId;
22
23
  heartbeatTimer = null;
23
24
  pollTimer = null;
25
+ cancelPollTimer = null;
24
26
  inFlight = /* @__PURE__ */ new Set();
27
+ /**
28
+ * P-Concern-J: track each in-flight job's child process so we can
29
+ * SIGTERM it when the cancel_requested flag fires. Cleared on
30
+ * runJob exit.
31
+ */
32
+ inFlightChildren = /* @__PURE__ */ new Map();
25
33
  stopped = false;
26
34
  constructor(opts) {
27
35
  this.baseUrl = opts.baseUrl.replace(/\/$/, "");
@@ -39,13 +47,18 @@ var BridgePoller = class {
39
47
  this.pollTimer = setInterval(() => {
40
48
  void this.poll();
41
49
  }, POLL_INTERVAL_MS);
50
+ this.cancelPollTimer = setInterval(() => {
51
+ void this.checkCancels();
52
+ }, CANCEL_POLL_INTERVAL_MS);
42
53
  }
43
54
  async stop() {
44
55
  this.stopped = true;
45
56
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
46
57
  if (this.pollTimer) clearInterval(this.pollTimer);
58
+ if (this.cancelPollTimer) clearInterval(this.cancelPollTimer);
47
59
  this.heartbeatTimer = null;
48
60
  this.pollTimer = null;
61
+ this.cancelPollTimer = null;
49
62
  }
50
63
  probeBinary(name) {
51
64
  if (!/^[a-z][a-z0-9-]*$/i.test(name)) return { available: false, path: null };
@@ -76,11 +89,68 @@ var BridgePoller = class {
76
89
  if (!resp.ok) {
77
90
  const text = await resp.text().catch(() => "");
78
91
  log.warn(`[bridge] heartbeat HTTP ${resp.status}: ${text.slice(0, 120)}`);
92
+ return;
79
93
  }
94
+ const data = await resp.json().catch(() => null);
95
+ this.handleCancelRequestedIds(data?.cancelRequestedJobs ?? []);
80
96
  } catch (err) {
81
97
  log.warn(`[bridge] heartbeat failed: ${err.message}`);
82
98
  }
83
99
  }
100
+ /**
101
+ * P-Concern-J: tighter-cadence cancel detection. Polls the
102
+ * heartbeat-style endpoint every 5s asking only for the cancel list
103
+ * (cheap query — partial index on cancel_requested). When a flagged
104
+ * job ID matches an in-flight one, SIGTERM the child process.
105
+ *
106
+ * We reuse the heartbeat endpoint itself rather than adding a new
107
+ * route; the endpoint already returns cancelRequestedJobs and the
108
+ * cost of the inner UPDATE is constant (idempotent).
109
+ */
110
+ async checkCancels() {
111
+ if (this.stopped || this.inFlight.size === 0) return;
112
+ try {
113
+ const resp = await fetch(`${this.baseUrl}/api/cli-bridge/heartbeat`, {
114
+ method: "POST",
115
+ headers: {
116
+ Authorization: `Bearer ${this.token}`,
117
+ "Content-Type": "application/json"
118
+ },
119
+ body: JSON.stringify({
120
+ availableClis: {
121
+ claude: this.probeBinary("claude"),
122
+ codex: this.probeBinary("codex")
123
+ },
124
+ clientVersion: this.clientVersion,
125
+ instanceId: this.instanceId
126
+ }),
127
+ signal: AbortSignal.timeout(15e3)
128
+ });
129
+ if (!resp.ok) return;
130
+ const data = await resp.json().catch(() => null);
131
+ this.handleCancelRequestedIds(data?.cancelRequestedJobs ?? []);
132
+ } catch {
133
+ }
134
+ }
135
+ handleCancelRequestedIds(ids) {
136
+ if (!ids.length) return;
137
+ for (const id of ids) {
138
+ const child = this.inFlightChildren.get(id);
139
+ if (!child) continue;
140
+ log.warn(`[bridge] cancel_requested for job ${id.slice(0, 8)} \u2014 SIGTERM-ing child pid=${child.pid}`);
141
+ try {
142
+ child.kill("SIGTERM");
143
+ setTimeout(() => {
144
+ try {
145
+ if (!child.killed) child.kill("SIGKILL");
146
+ } catch {
147
+ }
148
+ }, 3e3);
149
+ } catch (err) {
150
+ log.warn(`[bridge] SIGTERM failed for ${id.slice(0, 8)}: ${err.message}`);
151
+ }
152
+ }
153
+ }
84
154
  async poll() {
85
155
  if (this.stopped) return;
86
156
  const availableSlots = MAX_IN_FLIGHT_JOBS - this.inFlight.size;
@@ -136,9 +206,10 @@ var BridgePoller = class {
136
206
  await this.respond(job.id, { error: err.message, latencyMs });
137
207
  } finally {
138
208
  this.inFlight.delete(job.id);
209
+ this.inFlightChildren.delete(job.id);
139
210
  }
140
211
  }
141
- async invokeCli(cli, model, prompt) {
212
+ async invokeCli(cli, model, prompt, onSpawn) {
142
213
  const candidates = cli === "codex" ? model && /^[a-z0-9.\-]+$/i.test(model) ? [model] : ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"] : model && /^[a-z0-9-]+$/i.test(model) ? [model] : ["opus", "sonnet"];
143
214
  let lastErr = "";
144
215
  for (const m of candidates) {
@@ -151,7 +222,7 @@ var BridgePoller = class {
151
222
  "--allowedTools",
152
223
  "WebSearch,WebFetch"
153
224
  ];
154
- const text = await runSpawn(cli, args, prompt, CLI_TIMEOUT_MS);
225
+ const text = await runSpawn(cli, args, prompt, CLI_TIMEOUT_MS, onSpawn);
155
226
  if (text.trim()) return { text, modelUsed: `${cli}:${m}` };
156
227
  lastErr = `${cli}/${m} returned empty stdout`;
157
228
  } catch (err) {
@@ -161,24 +232,27 @@ var BridgePoller = class {
161
232
  return { error: lastErr || "no model produced output" };
162
233
  }
163
234
  async invokeJob(job) {
235
+ const onSpawn = (child) => {
236
+ this.inFlightChildren.set(job.id, child);
237
+ };
164
238
  const claudePromptEnvelope = parseClaudePromptEnvelope(job.prompt);
165
239
  if (claudePromptEnvelope) {
166
240
  if (job.cli !== "claude") return { error: `${claudePromptEnvelope.__forgeJobKind} envelope requires claude CLI` };
167
- return this.invokeClaudeWithEnvelope(job.model, claudePromptEnvelope);
241
+ return this.invokeClaudeWithEnvelope(job.model, claudePromptEnvelope, onSpawn);
168
242
  }
169
243
  const imagePayload = parseImageGenPayload(job.prompt);
170
244
  if (imagePayload) {
171
245
  if (job.cli !== "codex") return { error: "image-gen bridge jobs require codex CLI" };
172
- return this.invokeCodexImageGen(job.model, imagePayload);
246
+ return this.invokeCodexImageGen(job.model, imagePayload, onSpawn);
173
247
  }
174
- return this.invokeCli(job.cli, job.model, job.prompt);
248
+ return this.invokeCli(job.cli, job.model, job.prompt, onSpawn);
175
249
  }
176
250
  /**
177
251
  * Run a Claude prompt envelope. Web tools are gated by envelope.webTools:
178
252
  * ['WebSearch','WebFetch'] for fact-pack-build (research stage)
179
253
  * [] for script-from-pack (pack-only writing)
180
254
  */
181
- async invokeClaudeWithEnvelope(jobModel, envelope) {
255
+ async invokeClaudeWithEnvelope(jobModel, envelope, onSpawn) {
182
256
  const requestedModel = envelope.model || jobModel;
183
257
  const fallbackChain = envelope.__forgeJobKind === "fact-pack-build" ? ["opus", "sonnet"] : ["sonnet", "haiku"];
184
258
  const candidates = requestedModel && /^[a-z0-9-]+$/i.test(requestedModel) ? [requestedModel] : fallbackChain;
@@ -191,7 +265,7 @@ var BridgePoller = class {
191
265
  for (const m of candidates) {
192
266
  try {
193
267
  const args = baseArgs.map((a) => a === "<m>" ? m : a);
194
- const text = await runSpawn("claude", args, envelope.prompt, CLI_TIMEOUT_MS);
268
+ const text = await runSpawn("claude", args, envelope.prompt, CLI_TIMEOUT_MS, onSpawn);
195
269
  if (text.trim()) return { text, modelUsed: `claude:${m}${tools.length === 0 ? " (no-web)" : ""}` };
196
270
  lastErr = `claude/${m} returned empty stdout`;
197
271
  } catch (err) {
@@ -200,7 +274,7 @@ var BridgePoller = class {
200
274
  }
201
275
  return { error: lastErr || "no model produced output" };
202
276
  }
203
- async invokeCodexImageGen(model, payload) {
277
+ async invokeCodexImageGen(model, payload, onSpawn) {
204
278
  const candidates = codexImagegenModelCandidates(model);
205
279
  const count = Math.max(1, Math.min(4, Number(payload.count) || 1));
206
280
  const prompt = [
@@ -220,7 +294,7 @@ var BridgePoller = class {
220
294
  let lastErr = "";
221
295
  for (const m of candidates) {
222
296
  try {
223
- const stdout = await runSpawn("codex", ["exec", "-", "--model", m], prompt, IMAGE_GEN_TIMEOUT_MS);
297
+ const stdout = await runSpawn("codex", ["exec", "-", "--model", m], prompt, IMAGE_GEN_TIMEOUT_MS, onSpawn);
224
298
  const images = collectImagesFromCodexStdout(stdout, count);
225
299
  if (images.length === 0) {
226
300
  lastErr = `codex/${m} imagegen returned no readable image paths`;
@@ -332,9 +406,15 @@ function mimeTypeForPath(p) {
332
406
  if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
333
407
  return "image/png";
334
408
  }
335
- function runSpawn(cmd, args, stdin, timeoutMs) {
409
+ function runSpawn(cmd, args, stdin, timeoutMs, onSpawn) {
336
410
  return new Promise((resolve, reject) => {
337
411
  const proc = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
412
+ if (onSpawn) {
413
+ try {
414
+ onSpawn(proc);
415
+ } catch {
416
+ }
417
+ }
338
418
  let stdout = "";
339
419
  let stderr = "";
340
420
  const timer = setTimeout(() => {
@@ -354,8 +434,12 @@ function runSpawn(cmd, args, stdin, timeoutMs) {
354
434
  clearTimeout(timer);
355
435
  reject(err);
356
436
  });
357
- proc.on("close", (code) => {
437
+ proc.on("close", (code, signal) => {
358
438
  clearTimeout(timer);
439
+ if (signal === "SIGTERM" || signal === "SIGKILL") {
440
+ reject(new Error(`${cmd} terminated by ${signal} (likely user cancel)`));
441
+ return;
442
+ }
359
443
  if (code !== 0) reject(new Error(`${cmd} exit ${code}: ${stderr.slice(-300)}`));
360
444
  else resolve(stdout);
361
445
  });
package/dist/index.js CHANGED
@@ -1614,7 +1614,7 @@ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
1614
1614
  return "0.0.0";
1615
1615
  })();
1616
1616
  void (async () => {
1617
- const { BridgePoller } = await import("./bridge-poller-WEKSUZMT.js");
1617
+ const { BridgePoller } = await import("./bridge-poller-MM6K2GKM.js");
1618
1618
  const poller = new BridgePoller({ baseUrl: bridgeUrl, token: bridgeToken, clientVersion: `storyforge ${pkgVersion}` });
1619
1619
  poller.start();
1620
1620
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyforge",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "StoryForge — local bridge for the Forge video production web app. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "bin": {