@forgezero/runtime 0.1.1 → 0.1.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/dist/jobs.js CHANGED
@@ -6,6 +6,275 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
6
6
  throw Error('Dynamic require of "' + x + '" is not supported');
7
7
  });
8
8
 
9
+ // src/queue.ts
10
+ class QueueStoppedError extends Error {
11
+ constructor() {
12
+ super("queue: stopped before this task could run");
13
+ this.name = "QueueStoppedError";
14
+ }
15
+ }
16
+
17
+ class QueueKeyStoppedError extends Error {
18
+ key;
19
+ constructor(key) {
20
+ super(`queue: key ${key} is stopped`);
21
+ this.key = key;
22
+ this.name = "QueueKeyStoppedError";
23
+ }
24
+ }
25
+
26
+ class TaskCancelledError extends Error {
27
+ constructor() {
28
+ super("queue: task cancelled");
29
+ this.name = "TaskCancelledError";
30
+ }
31
+ }
32
+ var DEFAULT_RETRY = {
33
+ attempts: 1,
34
+ backoffMs: (attempt) => Math.min(30000, 2 ** attempt * 100)
35
+ };
36
+ function createQueue(options = {}) {
37
+ const width = options.width ?? 8;
38
+ const retry = { ...DEFAULT_RETRY, ...options.retry };
39
+ if (!Number.isSafeInteger(width) || width < 1) {
40
+ throw new RangeError("queue: width must be a positive integer");
41
+ }
42
+ if (!Number.isSafeInteger(retry.attempts) || retry.attempts < 1) {
43
+ throw new RangeError("queue: retry attempts must be a positive integer");
44
+ }
45
+ const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
46
+ const lanes = new Map;
47
+ const running = new Set;
48
+ const paused = new Set;
49
+ const stoppedKeys = new Set;
50
+ let sequence = 0;
51
+ let globallyPaused = false;
52
+ let accepting = true;
53
+ let aborted = false;
54
+ let completed = 0;
55
+ let failed = 0;
56
+ const idle = [];
57
+ const announceIdle = () => {
58
+ if (running.size > 0)
59
+ return;
60
+ for (const lane of lanes.values())
61
+ if (lane.length > 0)
62
+ return;
63
+ while (idle.length > 0)
64
+ idle.shift()();
65
+ };
66
+ function pump() {
67
+ if (globallyPaused || aborted) {
68
+ announceIdle();
69
+ return;
70
+ }
71
+ for (const [key, lane] of lanes) {
72
+ if (running.size >= width)
73
+ break;
74
+ if (running.has(key) || paused.has(key) || lane.length === 0)
75
+ continue;
76
+ execute(key);
77
+ }
78
+ announceIdle();
79
+ }
80
+ async function execute(key) {
81
+ running.add(key);
82
+ try {
83
+ const lane = lanes.get(key);
84
+ const entry = lane?.[0];
85
+ if (entry && !globallyPaused && !paused.has(key) && !aborted) {
86
+ lane.shift();
87
+ await attempt(entry);
88
+ }
89
+ } finally {
90
+ running.delete(key);
91
+ const remaining = lanes.get(key);
92
+ if (remaining?.length === 0)
93
+ lanes.delete(key);
94
+ else if (remaining) {
95
+ lanes.delete(key);
96
+ lanes.set(key, remaining);
97
+ }
98
+ pump();
99
+ }
100
+ }
101
+ async function attempt(entry) {
102
+ if (entry.cancelled) {
103
+ entry.reject(new TaskCancelledError);
104
+ return;
105
+ }
106
+ for (;; ) {
107
+ entry.attempt += 1;
108
+ try {
109
+ const value = await entry.run();
110
+ completed += 1;
111
+ entry.resolve(value);
112
+ return;
113
+ } catch (cause) {
114
+ if (entry.attempt >= retry.attempts || entry.cancelled || aborted) {
115
+ failed += 1;
116
+ entry.reject(cause);
117
+ return;
118
+ }
119
+ const delay = retry.backoffMs(entry.attempt);
120
+ if (!Number.isFinite(delay) || delay < 0) {
121
+ failed += 1;
122
+ entry.reject(new RangeError("queue: retry backoff must be a non-negative finite number"));
123
+ return;
124
+ }
125
+ await sleep(delay);
126
+ }
127
+ }
128
+ }
129
+ return {
130
+ run(key, handler, ...args) {
131
+ const id = `q_${++sequence}`;
132
+ if (!accepting || stoppedKeys.has(key)) {
133
+ const refused = Promise.reject(accepting ? new QueueKeyStoppedError(key) : new QueueStoppedError);
134
+ refused.catch(() => {
135
+ return;
136
+ });
137
+ return { id, key, result: refused };
138
+ }
139
+ let resolve;
140
+ let reject;
141
+ const result = new Promise((ok, no) => {
142
+ resolve = ok;
143
+ reject = no;
144
+ });
145
+ const entry = {
146
+ id,
147
+ key,
148
+ run: async () => handler(...args),
149
+ resolve,
150
+ reject,
151
+ attempt: 0,
152
+ cancelled: false
153
+ };
154
+ const lane = lanes.get(key);
155
+ if (lane)
156
+ lane.push(entry);
157
+ else
158
+ lanes.set(key, [entry]);
159
+ pump();
160
+ return { id, key, result };
161
+ },
162
+ cancel(id) {
163
+ for (const [key, lane] of lanes) {
164
+ const index = lane.findIndex((entry2) => entry2.id === id);
165
+ if (index === -1)
166
+ continue;
167
+ const [entry] = lane.splice(index, 1);
168
+ entry.cancelled = true;
169
+ entry.reject(new TaskCancelledError);
170
+ if (lane.length === 0 && !running.has(key))
171
+ lanes.delete(key);
172
+ announceIdle();
173
+ return true;
174
+ }
175
+ return false;
176
+ },
177
+ pauseKey(key) {
178
+ paused.add(key);
179
+ },
180
+ resumeKey(key) {
181
+ paused.delete(key);
182
+ pump();
183
+ },
184
+ stopKey(key) {
185
+ stoppedKeys.add(key);
186
+ paused.delete(key);
187
+ const lane = lanes.get(key);
188
+ if (!lane)
189
+ return 0;
190
+ let removed = 0;
191
+ for (const entry of lane.splice(0)) {
192
+ removed += 1;
193
+ entry.cancelled = true;
194
+ entry.reject(new QueueKeyStoppedError(key));
195
+ }
196
+ if (!running.has(key))
197
+ lanes.delete(key);
198
+ announceIdle();
199
+ return removed;
200
+ },
201
+ startKey(key) {
202
+ const changed = stoppedKeys.delete(key);
203
+ pump();
204
+ return changed;
205
+ },
206
+ pause() {
207
+ globallyPaused = true;
208
+ },
209
+ resume() {
210
+ globallyPaused = false;
211
+ pump();
212
+ },
213
+ snapshot() {
214
+ let queued = 0;
215
+ for (const lane of lanes.values())
216
+ queued += lane.length;
217
+ return {
218
+ running: running.size,
219
+ queued,
220
+ keys: lanes.size,
221
+ paused: globallyPaused,
222
+ pausedKeys: [...paused],
223
+ stoppedKeys: [...stoppedKeys],
224
+ completed,
225
+ failed
226
+ };
227
+ },
228
+ whenIdle() {
229
+ if (running.size === 0 && [...lanes.values()].every((lane) => lane.length === 0)) {
230
+ return Promise.resolve();
231
+ }
232
+ return new Promise((resolve) => idle.push(resolve));
233
+ },
234
+ async stop(deadlineMs = 30000) {
235
+ if (!Number.isSafeInteger(deadlineMs) || deadlineMs < 0) {
236
+ throw new RangeError("queue: stop deadline must be a non-negative integer");
237
+ }
238
+ accepting = false;
239
+ const before = { completed, failed };
240
+ globallyPaused = false;
241
+ paused.clear();
242
+ pump();
243
+ let timedOut = false;
244
+ let deadlineHandle;
245
+ const deadline = new Promise((resolve) => {
246
+ deadlineHandle = setTimeout(() => {
247
+ timedOut = true;
248
+ resolve();
249
+ }, deadlineMs);
250
+ });
251
+ await Promise.race([this.whenIdle(), deadline]);
252
+ if (!timedOut && deadlineHandle !== undefined)
253
+ clearTimeout(deadlineHandle);
254
+ if (timedOut)
255
+ aborted = true;
256
+ let abandoned = 0;
257
+ for (const lane of lanes.values()) {
258
+ abandoned += lane.length;
259
+ for (const entry of lane.splice(0))
260
+ entry.reject(new QueueStoppedError);
261
+ }
262
+ abandoned += running.size;
263
+ for (const [key, lane] of lanes) {
264
+ if (lane.length === 0 && !running.has(key))
265
+ lanes.delete(key);
266
+ }
267
+ announceIdle();
268
+ return {
269
+ completed: completed - before.completed,
270
+ failed: failed - before.failed,
271
+ abandoned,
272
+ timedOut
273
+ };
274
+ }
275
+ };
276
+ }
277
+
9
278
  // src/jobs.ts
10
279
  var systemClock = {
11
280
  now: () => Date.now(),
@@ -83,10 +352,13 @@ function createScheduler(options) {
83
352
  { key: job.key, label: job.label, state: "stopped", consecutiveFailures: 0, runs: 0, skippedLocked: 0 }
84
353
  ]));
85
354
  const timers = new Map;
86
- const inFlight = new Map;
355
+ let work = createQueue({ width: Math.max(1, jobs.size) });
356
+ let workStopped = false;
357
+ let restartBlocked = false;
87
358
  let controller = new AbortController;
88
359
  let paused = false;
89
360
  let running = false;
361
+ const submit = (job) => work.run(job.key, execute, job).result;
90
362
  async function execute(job) {
91
363
  const report = reports.get(job.key);
92
364
  const leaseMs = job.leaseMs ?? (job.every ? everyMs(job.every) * 4 : 60000);
@@ -132,17 +404,22 @@ function createScheduler(options) {
132
404
  timers.delete(job.key);
133
405
  if (!running || paused)
134
406
  return;
135
- const work = execute(job).finally(() => {
136
- inFlight.delete(job.key);
137
- schedule(job, everyMs(job.every));
407
+ submit(job).then(() => schedule(job, everyMs(job.every)), () => {
408
+ return;
138
409
  });
139
- inFlight.set(job.key, work);
140
410
  }, delayMs));
141
411
  }
142
412
  return {
143
413
  start() {
144
414
  if (running)
145
415
  return;
416
+ if (restartBlocked) {
417
+ throw new Error("scheduler: cannot restart after an incomplete drain while abandoned work may still run");
418
+ }
419
+ if (workStopped) {
420
+ work = createQueue({ width: Math.max(1, jobs.size) });
421
+ workStopped = false;
422
+ }
146
423
  running = true;
147
424
  paused = false;
148
425
  controller = new AbortController;
@@ -152,15 +429,18 @@ function createScheduler(options) {
152
429
  schedule(job, job.startDelayMs ?? spreadOf(job.key, Math.min(interval, 30000)));
153
430
  }
154
431
  },
155
- async stop() {
432
+ async stop(deadlineMs = 30000) {
156
433
  running = false;
157
434
  controller.abort();
158
435
  for (const timer of timers.values())
159
436
  clearTimeout(timer);
160
437
  timers.clear();
161
- await Promise.allSettled([...inFlight.values()]);
438
+ const drained = await work.stop(deadlineMs);
439
+ workStopped = true;
440
+ restartBlocked = drained.timedOut;
162
441
  for (const report of reports.values())
163
442
  report.state = "stopped";
443
+ return drained;
164
444
  },
165
445
  pause() {
166
446
  paused = true;
@@ -185,12 +465,7 @@ function createScheduler(options) {
185
465
  const job = jobs.get(key);
186
466
  if (!job)
187
467
  throw new Error(`No job named "${key}".`);
188
- const existing = inFlight.get(key);
189
- if (existing)
190
- await existing;
191
- const work = execute(job).finally(() => inFlight.delete(key));
192
- inFlight.set(key, work);
193
- await work;
468
+ await submit(job);
194
469
  return { ...reports.get(key) };
195
470
  },
196
471
  status: () => [...reports.values()].map((report) => ({ ...report })),
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Deciding whether a push should cause a deploy, and what that deploy is.
2
+ * Deciding whether a push should cause a deploy.
3
3
  *
4
4
  * A webhook receiver is a public endpoint that runs commands on your machines
5
5
  * when something posts to it. Everything worth getting right is in the gap
6
- * between those two facts, so this module holds the decisions and none of the
7
- * doing: no network, no shell, no clone. What it produces is a PLAN, and
8
- * something else carries it out.
6
+ * between those two facts, so this module holds trigger verification and none
7
+ * of the doing: no network, no shell, no clone, and no executable plan. The
8
+ * agent later reads the only deployment definition from the exact checked-out
9
+ * commit.
9
10
  *
10
11
  * That split is not tidiness. It means the interesting cases — a forged
11
12
  * signature, a push to a branch nobody deploys, two pushes racing, a repository
@@ -21,8 +22,8 @@
21
22
  * matters when reading logs during an incident.
22
23
  */
23
24
  export declare class PipelineError extends Error {
24
- readonly code: 'BAD_SIGNATURE' | 'UNSUPPORTED_PROVIDER' | 'MALFORMED_EVENT' | 'WRONG_REPOSITORY' | 'NO_SECRET';
25
- constructor(code: 'BAD_SIGNATURE' | 'UNSUPPORTED_PROVIDER' | 'MALFORMED_EVENT' | 'WRONG_REPOSITORY' | 'NO_SECRET', message: string);
25
+ readonly code: 'BAD_SIGNATURE' | 'UNSUPPORTED_PROVIDER' | 'MALFORMED_EVENT' | 'NO_SECRET';
26
+ constructor(code: 'BAD_SIGNATURE' | 'UNSUPPORTED_PROVIDER' | 'MALFORMED_EVENT' | 'NO_SECRET', message: string);
26
27
  }
27
28
  export declare const GIT_PROVIDERS: readonly ["github", "gitlab", "generic"];
28
29
  export type GitProvider = (typeof GIT_PROVIDERS)[number];
@@ -73,50 +74,23 @@ export interface PushEvent {
73
74
  * failure every time somebody opened an issue.
74
75
  */
75
76
  export declare function parsePush(provider: GitProvider, body: unknown): PushEvent | null;
76
- export interface PipelineConfig {
77
+ export interface DeployTrigger {
77
78
  provider: GitProvider;
78
79
  /** `owner/name`. Compared against the delivery. */
79
80
  repository: string;
80
81
  /** Only this branch deploys. One branch per pipeline, deliberately. */
81
82
  branch: string;
82
- /** Where the checkout lives on the compute. */
83
- workdir: string;
84
- /** Shell steps, in order. Empty means clone only. */
85
- steps: readonly string[];
86
- cloneUrl: string;
87
83
  }
88
- export declare const PLAN_ACTIONS: readonly ["clone", "fetch", "checkout", "run"];
89
- export type PlanAction = (typeof PLAN_ACTIONS)[number];
90
- export interface PlanStep {
91
- action: PlanAction;
92
- command: string;
93
- /** Shown to an operator. Never contains a credential. */
94
- label: string;
95
- }
96
- /**
97
- * The commands that would deploy this push, in order.
98
- *
99
- * `clone` and `fetch` are both emitted, guarded on the directory existing,
100
- * because a runner cannot know in advance whether the first deploy has
101
- * happened — and branching on that in the caller means two code paths where one
102
- * of them is exercised once per compute, ever.
103
- *
104
- * The commit is checked out by SHA rather than by branch. A branch moves: a
105
- * deploy that fetched and then checked out `main` could deploy a commit that
106
- * arrived after the one that triggered it, so the thing tested is not the thing
107
- * shipped. The SHA is what the webhook said, so what deploys is what fired.
108
- */
109
- export declare function planDeploy(config: PipelineConfig, event: PushEvent): PlanStep[];
110
84
  /**
111
85
  * Should this delivery deploy at all?
112
86
  *
113
- * Separate from `planDeploy` so "we received it and deliberately did nothing"
114
- * is a first-class outcome with a reason attached. A receiver that silently
87
+ * "We received it and deliberately did nothing" is a first-class outcome with
88
+ * a reason attached. A receiver that silently
115
89
  * ignored non-matching branches would be indistinguishable from one that is
116
90
  * broken, and the first question during an incident is always whether the hook
117
91
  * arrived.
118
92
  */
119
- export declare function shouldDeploy(config: PipelineConfig, event: PushEvent | null): {
93
+ export declare function shouldDeploy(config: Pick<DeployTrigger, 'repository' | 'branch'>, event: PushEvent | null): {
120
94
  deploy: boolean;
121
95
  reason: string;
122
96
  };
package/dist/pipeline.js CHANGED
@@ -72,31 +72,6 @@ function parsePush(provider, body) {
72
72
  message: typeof payload.head_commit?.message === "string" ? String(payload.head_commit.message) : undefined
73
73
  };
74
74
  }
75
- var PLAN_ACTIONS = ["clone", "fetch", "checkout", "run"];
76
- function planDeploy(config, event) {
77
- if (event.repository !== config.repository) {
78
- throw new PipelineError("WRONG_REPOSITORY", `That delivery is for ${event.repository}, and this pipeline deploys ${config.repository}.`);
79
- }
80
- const dir = config.workdir;
81
- return [
82
- {
83
- action: "clone",
84
- command: `[ -d ${dir}/.git ] || git clone ${config.cloneUrl} ${dir}`,
85
- label: "clone if this is the first deploy"
86
- },
87
- { action: "fetch", command: `git -C ${dir} fetch --prune origin`, label: "fetch" },
88
- {
89
- action: "checkout",
90
- command: `git -C ${dir} checkout --detach ${event.commit}`,
91
- label: `check out ${event.commit.slice(0, 8)}`
92
- },
93
- ...config.steps.map((step) => ({
94
- action: "run",
95
- command: `cd ${dir} && ${step}`,
96
- label: step
97
- }))
98
- ];
99
- }
100
75
  function shouldDeploy(config, event) {
101
76
  if (!event)
102
77
  return { deploy: false, reason: "Not a branch push — nothing to deploy." };
@@ -113,9 +88,7 @@ export {
113
88
  webhookPath,
114
89
  verifyWebhook,
115
90
  shouldDeploy,
116
- planDeploy,
117
91
  parsePush,
118
92
  PipelineError,
119
- PLAN_ACTIONS,
120
93
  GIT_PROVIDERS
121
94
  };
package/dist/queue.d.ts CHANGED
@@ -44,9 +44,8 @@ export interface QueueOptions {
44
44
  /** How many keys may run at once. Ordering within a key is unaffected. */
45
45
  width?: number;
46
46
  retry?: Partial<RetryPolicy>;
47
- /** Injected so tests need no timers and no wall clock. */
47
+ /** Retry delay injection. Shutdown deadlines use a cancellable native timer. */
48
48
  sleep?: (ms: number) => Promise<void>;
49
- now?: () => number;
50
49
  }
51
50
  export interface DrainReport {
52
51
  completed: number;
@@ -58,6 +57,10 @@ export interface DrainReport {
58
57
  export declare class QueueStoppedError extends Error {
59
58
  constructor();
60
59
  }
60
+ export declare class QueueKeyStoppedError extends Error {
61
+ readonly key: string;
62
+ constructor(key: string);
63
+ }
61
64
  export declare class TaskCancelledError extends Error {
62
65
  constructor();
63
66
  }
@@ -69,12 +72,22 @@ export declare function createQueue(options?: QueueOptions): {
69
72
  * rather than an id to poll — the previous `enqueue` returned only
70
73
  * `{ id, duplicate }` and had nowhere to put an answer.
71
74
  */
72
- run<T>(key: string, handler: () => Promise<T> | T): QueueTask<T>;
75
+ run<Args extends unknown[], T>(key: string, handler: (...args: Args) => Promise<T> | T, ...args: Args): QueueTask<T>;
73
76
  /** Remove a task that has not started. Running work is left alone. */
74
77
  cancel(id: string): boolean;
75
78
  /** Hold one key. Work already running for it finishes. */
76
79
  pauseKey(key: string): void;
77
80
  resumeKey(key: string): void;
81
+ /**
82
+ * Close one key and reject everything for it that has not started.
83
+ *
84
+ * JavaScript cannot safely kill an arbitrary running function. The current
85
+ * handler is therefore allowed to finish; every pending handler is removed,
86
+ * and future submissions are refused until `startKey()` is explicit.
87
+ */
88
+ stopKey(key: string): number;
89
+ /** Re-open a key deliberately; pausing and stopping are not aliases. */
90
+ startKey(key: string): boolean;
78
91
  /** Hold everything. New submissions are accepted and wait. */
79
92
  pause(): void;
80
93
  resume(): void;
@@ -85,6 +98,7 @@ export declare function createQueue(options?: QueueOptions): {
85
98
  keys: number;
86
99
  paused: boolean;
87
100
  pausedKeys: string[];
101
+ stoppedKeys: string[];
88
102
  completed: number;
89
103
  failed: number;
90
104
  };