@mazjq/zhiro 0.0.0-stage → 0.1.2

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,180 @@
1
+ import {
2
+ json,
3
+ now
4
+ } from "./chunk-5KCN32HQ.js";
5
+ import {
6
+ __name
7
+ } from "./chunk-2SSYR7VW.js";
8
+
9
+ // apps/daemon/src/service/workflow.ts
10
+ import { ulid } from "ulid";
11
+ var WorkflowEngine = class {
12
+ constructor(db, pollMs = 15e3) {
13
+ this.db = db;
14
+ this.pollMs = pollMs;
15
+ }
16
+ db;
17
+ pollMs;
18
+ static {
19
+ __name(this, "WorkflowEngine");
20
+ }
21
+ defs = /* @__PURE__ */ new Map();
22
+ timer = null;
23
+ /** told when a run of some kind finishes or gives up (a research report filed, a lead looked up): the pages hear of it */
24
+ onEnded;
25
+ register(def) {
26
+ this.defs.set(def.kind, def);
27
+ }
28
+ /** Idempotent start: same idempotencyKey → existing workflow id. */
29
+ start(kind, subjectId, idempotencyKey, checkpoint = {}) {
30
+ const def = this.defs.get(kind);
31
+ if (!def) throw new Error(`unknown workflow ${kind}`);
32
+ const found = this.db.prepare("select id from workflow where idempotency_key = ?").get(idempotencyKey);
33
+ if (found) return found.id;
34
+ const id = ulid();
35
+ const t = now();
36
+ this.db.prepare("insert into workflow(id, kind, subject_id, state, current_step, idempotency_key, checkpoint_json, next_run_at, created_at, updated_at) values (?,?,?,?,?,?,?,?,?,?)").run(id, kind, subjectId, "running", def.order[0], idempotencyKey, json.str(checkpoint), t, t, t);
37
+ return id;
38
+ }
39
+ /**
40
+ * For work that must keep going as long as its subject exists (a subscription's daily pull): start it, or — when an
41
+ * earlier run of it ended (its subject was removed and has come back, or it gave up) — set it going again from the
42
+ * first step. `start` alone would hand back the ended run and nothing would ever run again.
43
+ */
44
+ ensure(kind, subjectId, idempotencyKey) {
45
+ const id = this.start(kind, subjectId, idempotencyKey);
46
+ const t = now();
47
+ this.db.prepare("update workflow set state = 'running', current_step = ?, attempts = 0, next_run_at = ?, lease_until = null, last_error = '', updated_at = ? where id = ? and state in ('completed', 'failed')").run(this.defs.get(kind).order[0], t, t, id);
48
+ return id;
49
+ }
50
+ /** Its subject is gone: the work ends now, wherever it was waiting. `ensure` sets it going again if the subject returns. */
51
+ end(idempotencyKey) {
52
+ this.db.prepare("update workflow set state = 'completed', next_run_at = null, lease_until = null, updated_at = ? where idempotency_key = ? and state in ('running', 'waiting')").run(now(), idempotencyKey);
53
+ }
54
+ /** Resume a waiting workflow (e.g. an external event arrived). */
55
+ signal(id, patch = {}) {
56
+ const r = this.db.prepare("select checkpoint_json from workflow where id = ?").get(id);
57
+ if (!r) return;
58
+ const cp = { ...json.parse(r.checkpoint_json, {}), ...patch };
59
+ this.db.prepare("update workflow set state = 'running', next_run_at = ?, checkpoint_json = ?, updated_at = ? where id = ?").run(now(), json.str(cp), now(), id);
60
+ }
61
+ list(state) {
62
+ const rows = state ? this.db.prepare("select * from workflow where state = ? order by updated_at desc").all(state) : this.db.prepare("select * from workflow order by updated_at desc limit 200").all();
63
+ return rows.map((r) => ({ id: r.id, kind: r.kind, subjectId: r.subject_id, state: r.state, currentStep: r.current_step, nextRunAt: r.next_run_at, lastError: r.last_error }));
64
+ }
65
+ startPolling() {
66
+ if (this.timer) return;
67
+ const loop = /* @__PURE__ */ __name(() => {
68
+ try {
69
+ this.pump();
70
+ } catch (e) {
71
+ console.error("[workflow] poll failed", e);
72
+ }
73
+ this.timer = setTimeout(loop, this.pollMs);
74
+ }, "loop");
75
+ loop();
76
+ }
77
+ stopPolling() {
78
+ if (this.timer) clearTimeout(this.timer);
79
+ this.timer = null;
80
+ }
81
+ /**
82
+ * What each poll does: every kind of work that has something due, and is not already at it, is set going — kinds side by
83
+ * side, one kind's workflows one after another. One loop used to run everything in turn, so a career site that takes
84
+ * minutes to read held up a reminder, or a report that only needed filing (2026-10-13: a research answer waited behind a
85
+ * whole-site scan).
86
+ */
87
+ busy = /* @__PURE__ */ new Set();
88
+ pump() {
89
+ const t = now();
90
+ const kinds = this.db.prepare("select distinct kind from workflow where state = 'running' and (next_run_at is null or next_run_at <= ?) and (lease_until is null or lease_until < ?)").all(t, t);
91
+ for (const { kind } of kinds) {
92
+ if (this.busy.has(kind)) continue;
93
+ this.busy.add(kind);
94
+ void this.tick(now(), kind).catch((e) => console.error("[workflow] tick failed", kind, e)).finally(() => this.busy.delete(kind));
95
+ }
96
+ }
97
+ /** Run every due workflow once (of one kind, when given), one after another. Exposed for tests. */
98
+ async tick(nowIso = now(), kind) {
99
+ const due = this.db.prepare(`select * from workflow where state = 'running' and (next_run_at is null or next_run_at <= ?) and (lease_until is null or lease_until < ?)${kind ? " and kind = ?" : ""} order by next_run_at limit 20`).all(nowIso, nowIso, ...kind ? [kind] : []);
100
+ let ran = 0;
101
+ for (const w of due) {
102
+ const lease = new Date(Date.parse(nowIso) + 6e4).toISOString();
103
+ const claimed = this.db.prepare("update workflow set lease_until = ? where id = ? and (lease_until is null or lease_until < ?)").run(lease, w.id, nowIso);
104
+ if (claimed.changes === 0) continue;
105
+ await this.runOne(w);
106
+ ran++;
107
+ }
108
+ return ran;
109
+ }
110
+ async runOne(w) {
111
+ const def = this.defs.get(w.kind);
112
+ if (!def) {
113
+ this.finish(w.id, "failed", `no definition for ${w.kind}`);
114
+ return;
115
+ }
116
+ let step = w.current_step;
117
+ let checkpoint = json.parse(w.checkpoint_json, {});
118
+ let attempts = w.attempts;
119
+ for (let guard = 0; guard < 20; guard++) {
120
+ const handler = def.steps[step];
121
+ if (!handler) {
122
+ this.finish(w.id, "completed", "");
123
+ return;
124
+ }
125
+ let res;
126
+ try {
127
+ res = await handler({ subjectId: w.subject_id, checkpoint, attempts });
128
+ } catch (e) {
129
+ res = { type: "fail", error: String(e?.message ?? e), retryInMs: 5 * 6e4 };
130
+ }
131
+ if ("checkpoint" in res && res.checkpoint !== void 0) checkpoint = res.checkpoint;
132
+ if (res.type === "done") {
133
+ const i = def.order.indexOf(step);
134
+ const next = def.order[i + 1];
135
+ attempts = 0;
136
+ if (!next) {
137
+ this.update(w.id, { state: "completed", current_step: step, checkpoint, attempts, next_run_at: null, last_error: "" });
138
+ this.ended(w);
139
+ return;
140
+ }
141
+ step = next;
142
+ this.update(w.id, { state: "running", current_step: step, checkpoint, attempts, next_run_at: now(), last_error: "" });
143
+ continue;
144
+ }
145
+ if (res.type === "sleep") {
146
+ this.update(w.id, { state: "running", current_step: step, checkpoint, attempts, next_run_at: res.until, last_error: "" });
147
+ return;
148
+ }
149
+ if (res.type === "wait") {
150
+ this.update(w.id, { state: "waiting", current_step: step, checkpoint, attempts, next_run_at: null, last_error: "" });
151
+ return;
152
+ }
153
+ attempts++;
154
+ if (attempts >= 5) {
155
+ this.update(w.id, { state: "failed", current_step: step, checkpoint, attempts, next_run_at: null, last_error: res.error });
156
+ this.ended(w);
157
+ return;
158
+ }
159
+ this.update(w.id, { state: "running", current_step: step, checkpoint, attempts, next_run_at: new Date(Date.now() + (res.retryInMs ?? 6e4)).toISOString(), last_error: res.error });
160
+ return;
161
+ }
162
+ }
163
+ update(id, p) {
164
+ this.db.prepare("update workflow set state = ?, current_step = ?, checkpoint_json = ?, attempts = ?, next_run_at = ?, last_error = ?, lease_until = null, updated_at = ? where id = ?").run(p.state, p.current_step, json.str(p.checkpoint), p.attempts, p.next_run_at, p.last_error, now(), id);
165
+ }
166
+ ended(w) {
167
+ try {
168
+ this.onEnded?.(w.kind, w.subject_id);
169
+ } catch (e) {
170
+ console.error("[workflow] onEnded failed", e);
171
+ }
172
+ }
173
+ finish(id, state, err) {
174
+ this.db.prepare("update workflow set state = ?, last_error = ?, lease_until = null, next_run_at = null, updated_at = ? where id = ?").run(state, err, now(), id);
175
+ }
176
+ };
177
+
178
+ export {
179
+ WorkflowEngine
180
+ };
@@ -0,0 +1,6 @@
1
+ // apps/daemon/src/version.ts
2
+ var VERSION = true ? "0.1.2" : JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf8")).version;
3
+
4
+ export {
5
+ VERSION
6
+ };
@@ -0,0 +1,6 @@
1
+ // apps/daemon/src/env.ts
2
+ for (const [k, v] of Object.entries(process.env)) {
3
+ if (!k.startsWith("ZHIRO_") || v === void 0 || k === "ZHIRO_DATA") continue;
4
+ const old = `JOBOS_${k.slice(6)}`;
5
+ if (process.env[old] === void 0) process.env[old] = v;
6
+ }