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 +1 -1
- package/worker/index.js +13 -6
- package/worker/lock.js +103 -0
- package/worker/service.js +11 -2
- package/worker/style.js +1 -1
- package/worker/upload.js +27 -0
package/package.json
CHANGED
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 "
|
|
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
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
//
|
|
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(
|
|
224
|
-
|
|
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 "
|
|
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"]);
|
package/worker/upload.js
ADDED
|
@@ -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
|
+
}
|