goosetools-worker 0.2.1 → 0.2.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "goosetools-worker",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "The Goose Tools carousel worker — your computer drafts and renders carousel slides for goosetools.com using your own Claude account.",
5
5
  "repository": {
6
6
  "type": "git",
package/worker/index.js CHANGED
@@ -11,11 +11,19 @@ import { join, dirname, resolve, extname } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { promisify } from "node:util";
13
13
  import yaml from "js-yaml";
14
- import { upload } from "@vercel/blob/client";
14
+ import { upload } from "./upload.js";
15
15
  import { skillSection } from "./skill.js";
16
16
  import { extFromUrl } from "./style.js";
17
17
  import { FONT_SPECS, sanitizeFonts } from "../src/fonts.js";
18
18
  import { assertFfmpeg, isVideoPath, posterFrame } from "../src/video.js";
19
+ import { acquireWorkerLock } from "./lock.js";
20
+
21
+ // One worker of each kind per machine — a second one would split the queue
22
+ // with this one. Stands down with an explanation if another already holds it.
23
+ acquireWorkerLock("worker", {
24
+ label: "The Carousel Maker worker",
25
+ stopHint: "launchctl bootout gui/$(id -u)/com.goosetools.worker (or close its terminal)",
26
+ });
19
27
 
20
28
  const execFileAsync = promisify(execFile);
21
29
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -683,11 +691,10 @@ Do not rewrite any slide's text and do not switch templates.`;
683
691
  await api("complete", { jobId: job.id, ok: true, slidesYml, resultUrls });
684
692
  console.log(`✓ job ${job.id}: ${resultUrls.length} slides uploaded`);
685
693
 
686
- // The slides are safely in Blob storage now, so the local scratch dir (photos
687
- // + rendered PNGs, tens of MB per job) has done its job. Without this it
688
- // accumulates forever under carousels/. Only reached on success — a failed
689
- // job keeps its dir for debugging (the main loop's catch handles those).
690
- rmSync(dir, { recursive: true, force: true });
694
+ // The local copy stays. carousels/web-<id>/output/ is the creator's own
695
+ // archive of every deck this machine rendered — it is what let the renders
696
+ // the 2026 retention purge deleted be put back. Disk is theirs to spend;
697
+ // nothing a creator made is thrown away by a tool.
691
698
  }
692
699
 
693
700
  // ── Main loop ────────────────────────────────────────────────────────────────
package/worker/lock.js ADDED
@@ -0,0 +1,103 @@
1
+ // One worker of each kind per machine.
2
+ //
3
+ // Nothing stops you starting a second worker: the daemon runs in the
4
+ // background, and `run` in a terminal is the normal way to watch one work or
5
+ // to try a change from a checkout. Both then poll the same queue with the same
6
+ // token.
7
+ //
8
+ // The server is safe — claiming a job is a single compare-and-set, so two
9
+ // workers never get the same one. The damage is quieter than that. They SPLIT
10
+ // the queue, so jobs land on whichever copy happened to pick them up: half
11
+ // from the code you're editing, half from the installed release, with a
12
+ // different state directory behind each. And the polling doubles, which is
13
+ // what the idle interval exists to keep down in the first place.
14
+ //
15
+ // So: whoever gets here first holds the lock, and the second one stands down
16
+ // with an explanation instead of quietly competing.
17
+
18
+ import { execFileSync } from "node:child_process";
19
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ const LOCK_DIR = join(homedir(), ".goosetools", "locks");
24
+
25
+ /**
26
+ * Take the lock for `name` ("worker", "caption", "brand", "overlay"), or print
27
+ * who has it and exit. Returns nothing — it either succeeds or ends the
28
+ * process.
29
+ *
30
+ * `stopHint` is the command that stops the OTHER copy, and it's the whole
31
+ * point of the message: "already running" without it just moves the puzzle.
32
+ */
33
+ export function acquireWorkerLock(name, { label, stopHint }) {
34
+ mkdirSync(LOCK_DIR, { recursive: true });
35
+ const file = join(LOCK_DIR, `${name}.pid`);
36
+
37
+ const holder = readHolder(file);
38
+ if (holder && isAlive(holder.pid)) {
39
+ console.log(
40
+ `\n${label} is already running on this computer (pid ${holder.pid}${
41
+ holder.since ? `, since ${holder.since}` : ""
42
+ }).\n\n` +
43
+ "Two of them would split the queue between them — some jobs done by\n" +
44
+ "one copy, some by the other. Stopping here instead.\n\n" +
45
+ ` Stop the other one: ${stopHint}\n`,
46
+ );
47
+ process.exit(0);
48
+ }
49
+
50
+ // Either no lock, or one left behind by a worker that was killed. Both are
51
+ // ours to take: an O_EXCL create loses to a worker that beat us here by
52
+ // milliseconds, which is the one race worth caring about.
53
+ if (holder) rmSync(file, { force: true });
54
+ let fd;
55
+ try {
56
+ fd = openSync(file, "wx");
57
+ } catch {
58
+ console.log(`\n${label} started somewhere else a moment ago. Stopping here.\n`);
59
+ process.exit(0);
60
+ }
61
+ writeSync(fd, `${process.pid}\n${new Date().toISOString()}\n`);
62
+ closeSync(fd);
63
+
64
+ const release = () => rmSync(file, { force: true });
65
+ process.on("exit", release);
66
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
67
+ process.on(sig, () => {
68
+ release();
69
+ process.exit(0);
70
+ });
71
+ }
72
+ }
73
+
74
+ function readHolder(file) {
75
+ if (!existsSync(file)) return null;
76
+ try {
77
+ const [pid, since] = readFileSync(file, "utf8").split("\n");
78
+ const n = Number.parseInt(pid, 10);
79
+ return Number.isFinite(n) ? { pid: n, since: since?.trim() || null } : null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ // A pid file outlives a SIGKILLed worker, and pids get reused — so "is that
86
+ // pid alive" isn't enough on its own. Checking that it's a node process is
87
+ // cheap and rules out the reuse case that would otherwise lock out the daemon
88
+ // until someone deleted the file by hand.
89
+ function isAlive(pid) {
90
+ try {
91
+ process.kill(pid, 0);
92
+ } catch {
93
+ return false;
94
+ }
95
+ try {
96
+ return /node/.test(execFileSync("ps", ["-p", String(pid), "-o", "command="], {
97
+ encoding: "utf8",
98
+ stdio: ["ignore", "pipe", "ignore"],
99
+ }));
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
package/worker/service.js CHANGED
@@ -217,11 +217,20 @@ export function install({ url, token, root }) {
217
217
  }
218
218
  }
219
219
 
220
+ // Removes THIS worker, not the whole ~/.goosetools directory. It used to take
221
+ // the directory, which was harmless while everything in there was either this
222
+ // worker's or re-derivable from the server — and stopped being harmless when
223
+ // the Overlay Factory started keeping its puzzle library, learned looks and
224
+ // used-card record in ~/.goosetools/overlay. Nothing else has a copy of those,
225
+ // and `npx goosetools uninstall` runs this. The shared token file stays: the
226
+ // other workers are still using it.
220
227
  export function uninstall() {
221
228
  if (process.platform === "darwin") macUninstall();
222
229
  else if (process.platform === "win32") winUninstall();
223
- rmSync(GOOSE_DIR, { recursive: true, force: true });
224
- console.log("✓ Background worker removed. This computer is disconnected from Goose Tools.");
230
+ rmSync(APP_PREFIX, { recursive: true, force: true });
231
+ rmSync(join(GOOSE_DIR, "worker.log"), { force: true });
232
+ rmSync(join(GOOSE_DIR, "worker.err.log"), { force: true });
233
+ console.log("✓ Carousel worker removed. This computer no longer makes carousels.");
225
234
  }
226
235
 
227
236
  // ── update ───────────────────────────────────────────────────────────────────
package/worker/style.js CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  } from "node:fs";
16
16
  import { extname, join } from "node:path";
17
17
  import yaml from "js-yaml";
18
- import { upload } from "@vercel/blob/client";
18
+ import { upload } from "./upload.js";
19
19
  import { skillSection } from "./skill.js";
20
20
 
21
21
  const RESERVED_SLUGS = new Set(["graph-paper", "polka-dot"]);
@@ -0,0 +1,27 @@
1
+ // Worker-side upload, same signature as `upload` from "@vercel/blob/client":
2
+ //
3
+ // const blob = await upload(pathname, bytes, {
4
+ // handleUploadUrl, clientPayload, contentType,
5
+ // });
6
+ // blob.url
7
+ //
8
+ // Goose Tools moved file storage to Cloudflare R2. The upload route still
9
+ // decides who may write where (worker token in clientPayload), but instead
10
+ // of a Blob token it hands back a signed PUT URL; the bytes go straight to
11
+ // the bucket.
12
+
13
+ export async function upload(pathname, bytes, opts) {
14
+ const contentType = opts.contentType ?? "application/octet-stream";
15
+ const res = await fetch(opts.handleUploadUrl, {
16
+ method: "POST",
17
+ headers: { "content-type": "application/json" },
18
+ body: JSON.stringify({ pathname, contentType, size: bytes.length ?? bytes.byteLength ?? 0, clientPayload: opts.clientPayload ?? null }),
19
+ });
20
+ const grant = await res.json().catch(() => ({}));
21
+ if (!res.ok || !grant.uploadUrl || !grant.url) {
22
+ throw new Error(grant.error ?? `upload not allowed (${res.status})`);
23
+ }
24
+ const put = await fetch(grant.uploadUrl, { method: "PUT", headers: { "content-type": contentType }, body: bytes });
25
+ if (!put.ok) throw new Error(`upload PUT failed (${put.status})`);
26
+ return { url: grant.url, pathname: grant.pathname ?? pathname, contentType };
27
+ }