@forgezero/runtime 0.1.1 → 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.
- package/README.md +27 -4
- package/dist/audit.js +262 -23
- package/dist/jobs.d.ts +3 -2
- package/dist/jobs.js +288 -13
- package/dist/pipeline.d.ts +11 -37
- package/dist/pipeline.js +0 -27
- package/dist/queue.d.ts +17 -3
- package/dist/queue.js +86 -19
- package/dist/snp.d.ts +7 -6
- package/dist/snp.js +6 -1
- package/package.json +3 -7
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ themselves, keyed queues, a transactional outbox, a hash-chained audit trail,
|
|
|
5
5
|
templated mail, encrypted backups, schema validation, and money that is never a
|
|
6
6
|
floating-point number.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Thirty-three public modules. Each is its own entry point, so you install one package and
|
|
9
9
|
your bundler includes only what you imported.
|
|
10
10
|
|
|
11
11
|
```bash
|
|
@@ -24,7 +24,7 @@ import { createScheduler } from '@forgezero/runtime/jobs';
|
|
|
24
24
|
|
|
25
25
|
| | |
|
|
26
26
|
|---|---|
|
|
27
|
-
| **Spine** | `jobs` `queue` `
|
|
27
|
+
| **Spine** | `jobs` `queue` `outbox` |
|
|
28
28
|
| **Record** | `audit` `backup` `compliance` `calendar` |
|
|
29
29
|
| **Identity** | `identity` `totp` `notify` `notify/templates` `schema` `schema/typebox` |
|
|
30
30
|
| **Finance** | `finance/money` `finance/storage` `finance/ledger` `finance/commission` `finance/rates` `finance/transfers` `finance/tax` `finance/chain` `finance/custody` `finance/derive` `finance/venues` `finance/binance` |
|
|
@@ -32,6 +32,29 @@ import { createScheduler } from '@forgezero/runtime/jobs';
|
|
|
32
32
|
None of it knows what your product does. You bind your own rules to these; you
|
|
33
33
|
do not fork them.
|
|
34
34
|
|
|
35
|
+
## One reusable async queue
|
|
36
|
+
|
|
37
|
+
`queue` is deliberately memory-only: it accepts a function, returns that
|
|
38
|
+
function's value through a Promise, runs one key sequentially, and runs
|
|
39
|
+
different keys in parallel. Persistence and cluster ownership remain in the
|
|
40
|
+
business layer that knows what a pending request means; ForgeZero uses an
|
|
41
|
+
ArangoDB unique claim, while another caller may use any database or no database.
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { createQueue } from '@forgezero/runtime/queue';
|
|
45
|
+
|
|
46
|
+
const queue = createQueue({ width: 8 });
|
|
47
|
+
const task = queue.run('tenant-a:wallet-7', transfer, amount, destination);
|
|
48
|
+
const receipt = await task.result;
|
|
49
|
+
|
|
50
|
+
queue.pauseKey('tenant-a:wallet-7');
|
|
51
|
+
queue.resumeKey('tenant-a:wallet-7');
|
|
52
|
+
queue.stopKey('tenant-a:wallet-7'); // running finishes; pending is rejected
|
|
53
|
+
queue.startKey('tenant-a:wallet-7');
|
|
54
|
+
queue.cancel(task.id); // pending task only
|
|
55
|
+
await queue.stop(30_000); // close intake and drain all work
|
|
56
|
+
```
|
|
57
|
+
|
|
35
58
|
## Three things worth knowing before you use it
|
|
36
59
|
|
|
37
60
|
**Money is never a number.** An amount is minor units as a `bigint` with its
|
|
@@ -81,9 +104,9 @@ node_modules/@forgezero/runtime/contracts/src/
|
|
|
81
104
|
ColdVault.sol M-of-N EIP-712, signatures ordered by ascending signer
|
|
82
105
|
```
|
|
83
106
|
|
|
84
|
-
Full documentation: **https://forgezero.net/docs/runtime**
|
|
107
|
+
Full documentation: **https://www.forgezero.net/docs/runtime**
|
|
85
108
|
|
|
86
109
|
## Licence
|
|
87
110
|
|
|
88
|
-
MIT. Part of [ForgeZero](https://forgezero.net) — secrets, attested compute and
|
|
111
|
+
MIT. Part of [ForgeZero](https://www.forgezero.net) — secrets, attested compute and
|
|
89
112
|
deploys — and usable entirely on its own, with no ForgeZero account.
|
package/dist/audit.js
CHANGED
|
@@ -6,32 +6,271 @@ 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/
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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)
|
|
18
62
|
return;
|
|
19
|
-
|
|
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);
|
|
20
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;
|
|
21
144
|
});
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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();
|
|
29
231
|
}
|
|
30
|
-
return
|
|
232
|
+
return new Promise((resolve) => idle.push(resolve));
|
|
31
233
|
},
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
+
};
|
|
35
274
|
}
|
|
36
275
|
};
|
|
37
276
|
}
|
|
@@ -201,8 +440,8 @@ async function verifyExport(slice, options = {}) {
|
|
|
201
440
|
}
|
|
202
441
|
function createAuditChain(options) {
|
|
203
442
|
const digester = options.digester ?? hashDigester;
|
|
204
|
-
const
|
|
205
|
-
const enqueue = (realm, run) =>
|
|
443
|
+
const queue = createQueue();
|
|
444
|
+
const enqueue = (realm, run) => queue.run(realm ?? "\x00platform", run).result;
|
|
206
445
|
return {
|
|
207
446
|
append: (entry) => enqueue(entry.realm, () => appendRecord(options.store, entry, { ...options, digester })),
|
|
208
447
|
verify: async (realm, fromSequence = 1, toSequence = Number.MAX_SAFE_INTEGER) => {
|
package/dist/jobs.d.ts
CHANGED
|
@@ -15,11 +15,12 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Each has a specific answer here: reschedule after completion rather than on
|
|
17
17
|
* an interval, advance only on a fully successful batch, take a fenced lock,
|
|
18
|
-
* and
|
|
18
|
+
* and submit every run through the common awaited queue, which drains on stop.
|
|
19
19
|
*
|
|
20
20
|
* Zero dependencies. The lock and the cursor store are interfaces, so this runs
|
|
21
21
|
* against a database, Redis, or nothing at all in a test.
|
|
22
22
|
*/
|
|
23
|
+
import { type DrainReport } from './queue';
|
|
23
24
|
/** Injected so a test does not sleep and a resumed run is reproducible. */
|
|
24
25
|
export interface Clock {
|
|
25
26
|
now(): number;
|
|
@@ -122,7 +123,7 @@ export declare function createScheduler(options: SchedulerOptions): {
|
|
|
122
123
|
* leaves a record half-written — the process exits mid-transaction and the
|
|
123
124
|
* next boot finds state nothing can explain.
|
|
124
125
|
*/
|
|
125
|
-
stop(): Promise<
|
|
126
|
+
stop(deadlineMs?: number): Promise<DrainReport>;
|
|
126
127
|
/** Timers stop firing; in-flight work is left to finish. */
|
|
127
128
|
pause(): void;
|
|
128
129
|
resume(): void;
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
|
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
|
-
|
|
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 })),
|
package/dist/pipeline.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Deciding whether a push should cause a deploy
|
|
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
|
|
7
|
-
* doing: no network, no shell, no clone
|
|
8
|
-
*
|
|
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' | '
|
|
25
|
-
constructor(code: 'BAD_SIGNATURE' | 'UNSUPPORTED_PROVIDER' | 'MALFORMED_EVENT' | '
|
|
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
|
|
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
|
-
*
|
|
114
|
-
*
|
|
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:
|
|
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
|
-
/**
|
|
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
|
};
|
package/dist/queue.js
CHANGED
|
@@ -14,6 +14,15 @@ class QueueStoppedError extends Error {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
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
|
+
|
|
17
26
|
class TaskCancelledError extends Error {
|
|
18
27
|
constructor() {
|
|
19
28
|
super("queue: task cancelled");
|
|
@@ -25,12 +34,19 @@ var DEFAULT_RETRY = {
|
|
|
25
34
|
backoffMs: (attempt) => Math.min(30000, 2 ** attempt * 100)
|
|
26
35
|
};
|
|
27
36
|
function createQueue(options = {}) {
|
|
28
|
-
const width =
|
|
37
|
+
const width = options.width ?? 8;
|
|
29
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
|
+
}
|
|
30
45
|
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
31
46
|
const lanes = new Map;
|
|
32
47
|
const running = new Set;
|
|
33
48
|
const paused = new Set;
|
|
49
|
+
const stoppedKeys = new Set;
|
|
34
50
|
let sequence = 0;
|
|
35
51
|
let globallyPaused = false;
|
|
36
52
|
let accepting = true;
|
|
@@ -48,8 +64,10 @@ function createQueue(options = {}) {
|
|
|
48
64
|
idle.shift()();
|
|
49
65
|
};
|
|
50
66
|
function pump() {
|
|
51
|
-
if (globallyPaused || aborted)
|
|
67
|
+
if (globallyPaused || aborted) {
|
|
68
|
+
announceIdle();
|
|
52
69
|
return;
|
|
70
|
+
}
|
|
53
71
|
for (const [key, lane] of lanes) {
|
|
54
72
|
if (running.size >= width)
|
|
55
73
|
break;
|
|
@@ -62,18 +80,21 @@ function createQueue(options = {}) {
|
|
|
62
80
|
async function execute(key) {
|
|
63
81
|
running.add(key);
|
|
64
82
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (!entry || globallyPaused || paused.has(key) || aborted)
|
|
69
|
-
break;
|
|
83
|
+
const lane = lanes.get(key);
|
|
84
|
+
const entry = lane?.[0];
|
|
85
|
+
if (entry && !globallyPaused && !paused.has(key) && !aborted) {
|
|
70
86
|
lane.shift();
|
|
71
87
|
await attempt(entry);
|
|
72
88
|
}
|
|
73
89
|
} finally {
|
|
74
90
|
running.delete(key);
|
|
75
|
-
|
|
91
|
+
const remaining = lanes.get(key);
|
|
92
|
+
if (remaining?.length === 0)
|
|
76
93
|
lanes.delete(key);
|
|
94
|
+
else if (remaining) {
|
|
95
|
+
lanes.delete(key);
|
|
96
|
+
lanes.set(key, remaining);
|
|
97
|
+
}
|
|
77
98
|
pump();
|
|
78
99
|
}
|
|
79
100
|
}
|
|
@@ -95,15 +116,21 @@ function createQueue(options = {}) {
|
|
|
95
116
|
entry.reject(cause);
|
|
96
117
|
return;
|
|
97
118
|
}
|
|
98
|
-
|
|
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);
|
|
99
126
|
}
|
|
100
127
|
}
|
|
101
128
|
}
|
|
102
129
|
return {
|
|
103
|
-
run(key, handler) {
|
|
130
|
+
run(key, handler, ...args) {
|
|
104
131
|
const id = `q_${++sequence}`;
|
|
105
|
-
if (!accepting) {
|
|
106
|
-
const refused = Promise.reject(new QueueStoppedError);
|
|
132
|
+
if (!accepting || stoppedKeys.has(key)) {
|
|
133
|
+
const refused = Promise.reject(accepting ? new QueueKeyStoppedError(key) : new QueueStoppedError);
|
|
107
134
|
refused.catch(() => {
|
|
108
135
|
return;
|
|
109
136
|
});
|
|
@@ -118,7 +145,7 @@ function createQueue(options = {}) {
|
|
|
118
145
|
const entry = {
|
|
119
146
|
id,
|
|
120
147
|
key,
|
|
121
|
-
run: async () => handler(),
|
|
148
|
+
run: async () => handler(...args),
|
|
122
149
|
resolve,
|
|
123
150
|
reject,
|
|
124
151
|
attempt: 0,
|
|
@@ -142,6 +169,7 @@ function createQueue(options = {}) {
|
|
|
142
169
|
entry.reject(new TaskCancelledError);
|
|
143
170
|
if (lane.length === 0 && !running.has(key))
|
|
144
171
|
lanes.delete(key);
|
|
172
|
+
announceIdle();
|
|
145
173
|
return true;
|
|
146
174
|
}
|
|
147
175
|
return false;
|
|
@@ -153,6 +181,28 @@ function createQueue(options = {}) {
|
|
|
153
181
|
paused.delete(key);
|
|
154
182
|
pump();
|
|
155
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
|
+
},
|
|
156
206
|
pause() {
|
|
157
207
|
globallyPaused = true;
|
|
158
208
|
},
|
|
@@ -170,6 +220,7 @@ function createQueue(options = {}) {
|
|
|
170
220
|
keys: lanes.size,
|
|
171
221
|
paused: globallyPaused,
|
|
172
222
|
pausedKeys: [...paused],
|
|
223
|
+
stoppedKeys: [...stoppedKeys],
|
|
173
224
|
completed,
|
|
174
225
|
failed
|
|
175
226
|
};
|
|
@@ -181,15 +232,25 @@ function createQueue(options = {}) {
|
|
|
181
232
|
return new Promise((resolve) => idle.push(resolve));
|
|
182
233
|
},
|
|
183
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
|
+
}
|
|
184
238
|
accepting = false;
|
|
185
239
|
const before = { completed, failed };
|
|
240
|
+
globallyPaused = false;
|
|
241
|
+
paused.clear();
|
|
242
|
+
pump();
|
|
186
243
|
let timedOut = false;
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
244
|
+
let deadlineHandle;
|
|
245
|
+
const deadline = new Promise((resolve) => {
|
|
246
|
+
deadlineHandle = setTimeout(() => {
|
|
190
247
|
timedOut = true;
|
|
191
|
-
|
|
192
|
-
|
|
248
|
+
resolve();
|
|
249
|
+
}, deadlineMs);
|
|
250
|
+
});
|
|
251
|
+
await Promise.race([this.whenIdle(), deadline]);
|
|
252
|
+
if (!timedOut && deadlineHandle !== undefined)
|
|
253
|
+
clearTimeout(deadlineHandle);
|
|
193
254
|
if (timedOut)
|
|
194
255
|
aborted = true;
|
|
195
256
|
let abandoned = 0;
|
|
@@ -199,6 +260,11 @@ function createQueue(options = {}) {
|
|
|
199
260
|
entry.reject(new QueueStoppedError);
|
|
200
261
|
}
|
|
201
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();
|
|
202
268
|
return {
|
|
203
269
|
completed: completed - before.completed,
|
|
204
270
|
failed: failed - before.failed,
|
|
@@ -211,5 +277,6 @@ function createQueue(options = {}) {
|
|
|
211
277
|
export {
|
|
212
278
|
createQueue,
|
|
213
279
|
TaskCancelledError,
|
|
214
|
-
QueueStoppedError
|
|
280
|
+
QueueStoppedError,
|
|
281
|
+
QueueKeyStoppedError
|
|
215
282
|
};
|
package/dist/snp.d.ts
CHANGED
|
@@ -17,10 +17,9 @@
|
|
|
17
17
|
* ## What this does NOT do
|
|
18
18
|
*
|
|
19
19
|
* It does not verify the signature. Chaining a report to AMD's root needs the
|
|
20
|
-
* VCEK certificate for that specific chip at that specific TCB
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* would produce `verified: true` for a report nobody signed.
|
|
20
|
+
* VCEK certificate for that specific chip at that specific TCB and is performed
|
|
21
|
+
* by the API's pinned KDS verifier. Keeping network trust out of this parser
|
|
22
|
+
* preserves a total byte-to-fields function.
|
|
24
23
|
*
|
|
25
24
|
* So `parseSnpReport` returns the signature bytes and says nothing about them.
|
|
26
25
|
* The caller supplies a verifier, which is the same shape every other trust
|
|
@@ -29,8 +28,8 @@
|
|
|
29
28
|
/** The structure is exactly this long. Anything else is not a report. */
|
|
30
29
|
export declare const REPORT_BYTES = 1184;
|
|
31
30
|
export declare class SnpError extends Error {
|
|
32
|
-
readonly code: 'BAD_LENGTH' | 'BAD_VERSION' | 'BAD_VMPL';
|
|
33
|
-
constructor(code: 'BAD_LENGTH' | 'BAD_VERSION' | 'BAD_VMPL', message: string);
|
|
31
|
+
readonly code: 'BAD_LENGTH' | 'BAD_VERSION' | 'BAD_VMPL' | 'BAD_SIGNATURE_ALGORITHM';
|
|
32
|
+
constructor(code: 'BAD_LENGTH' | 'BAD_VERSION' | 'BAD_VMPL' | 'BAD_SIGNATURE_ALGORITHM', message: string);
|
|
34
33
|
}
|
|
35
34
|
/**
|
|
36
35
|
* Guest policy bits.
|
|
@@ -61,6 +60,8 @@ export interface SnpReport {
|
|
|
61
60
|
guestSvn: number;
|
|
62
61
|
policy: GuestPolicy;
|
|
63
62
|
vmpl: number;
|
|
63
|
+
/** 1 is ECDSA P-384 with SHA-384. */
|
|
64
|
+
signatureAlgorithm: number;
|
|
64
65
|
/** 48 bytes of hex. What the guest actually booted. */
|
|
65
66
|
measurement: string;
|
|
66
67
|
/** 64 bytes of hex. Whatever the guest asked the PSP to bind in — our nonce. */
|
package/dist/snp.js
CHANGED
|
@@ -69,19 +69,24 @@ function parseSnpReport(bytes) {
|
|
|
69
69
|
}
|
|
70
70
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
71
71
|
const version = view.getUint32(OFFSET.version, true);
|
|
72
|
-
if (version !== 2 && version !== 3) {
|
|
72
|
+
if (version !== 2 && version !== 3 && version !== 5) {
|
|
73
73
|
throw new SnpError("BAD_VERSION", `Report version ${version} is not one this parser understands.`);
|
|
74
74
|
}
|
|
75
75
|
const vmpl = view.getUint32(OFFSET.vmpl, true);
|
|
76
76
|
if (vmpl > 3) {
|
|
77
77
|
throw new SnpError("BAD_VMPL", `VMPL ${vmpl} is outside the defined range.`);
|
|
78
78
|
}
|
|
79
|
+
const signatureAlgorithm = view.getUint32(OFFSET.signatureAlgo, true);
|
|
80
|
+
if (signatureAlgorithm !== 1) {
|
|
81
|
+
throw new SnpError("BAD_SIGNATURE_ALGORITHM", `Signature algorithm ${signatureAlgorithm} is not ECDSA P-384 with SHA-384.`);
|
|
82
|
+
}
|
|
79
83
|
const slice = (offset, length) => hex(bytes.subarray(offset, offset + length));
|
|
80
84
|
return {
|
|
81
85
|
version,
|
|
82
86
|
guestSvn: view.getUint32(OFFSET.guestSvn, true),
|
|
83
87
|
policy: readPolicy(view, OFFSET.policy),
|
|
84
88
|
vmpl,
|
|
89
|
+
signatureAlgorithm,
|
|
85
90
|
measurement: slice(OFFSET.measurement, 48),
|
|
86
91
|
reportData: slice(OFFSET.reportData, 64),
|
|
87
92
|
hostData: slice(OFFSET.hostData, 32),
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "Publishing happens from an operator's machine, not CI \u2014 CLAUDE.md records that the absence of CI is deliberate. npm's `provenance` attests a tarball was built by a recognised CI provider from a named commit, so it cannot be produced here: it was set, and the first publish failed with `Automatic provenance generation not supported for provider: null`. A setting that can never be satisfied is worse than none, because it reads as a guarantee nobody is getting. Restore it the day this publishes from CI, and not before.",
|
|
3
3
|
"name": "@forgezero/runtime",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
@@ -59,10 +59,6 @@
|
|
|
59
59
|
"types": "./dist/otpauth.d.ts",
|
|
60
60
|
"default": "./dist/otpauth.js"
|
|
61
61
|
},
|
|
62
|
-
"./serial": {
|
|
63
|
-
"types": "./dist/serial.d.ts",
|
|
64
|
-
"default": "./dist/serial.js"
|
|
65
|
-
},
|
|
66
62
|
"./pipeline": {
|
|
67
63
|
"types": "./dist/pipeline.d.ts",
|
|
68
64
|
"default": "./dist/pipeline.js"
|
|
@@ -182,7 +178,7 @@
|
|
|
182
178
|
},
|
|
183
179
|
"scripts": {
|
|
184
180
|
"check": "tsc --noEmit",
|
|
185
|
-
"build": "bun build src/jobs.ts src/queue.ts src/
|
|
181
|
+
"build": "bun build src/jobs.ts src/queue.ts src/outbox.ts src/audit.ts src/backup.ts src/notify.ts src/notify-templates.ts src/calendar.ts src/compliance.ts src/pipeline.ts src/totp.ts src/otpauth.ts src/identity.ts src/slip10.ts src/openssh.ts src/ssh-cert.ts src/importers.ts src/snp.ts src/passkey.ts src/custody-crypto.ts src/custody-share.ts src/phrase.ts src/ssh-agent.ts src/schema.ts src/schema-typebox.ts src/finance/discounts.ts src/finance/money.ts src/finance/storage.ts src/finance/custody.ts src/finance/tax.ts src/finance/derive.ts src/finance/venues.ts src/finance/ledger.ts src/finance/rates.ts src/finance/transfers.ts src/finance/chain.ts src/finance/chain-addresses.ts src/finance/chain-deposits.ts src/finance/chain-withdrawals.ts src/finance/chain-reconcile.ts src/finance/market.ts src/finance/commission.ts --root src --outdir dist --target browser --format esm --packages external && tsc --emitDeclarationOnly --declaration --noEmit false --outDir dist",
|
|
186
182
|
"prepublishOnly": "bun run check && bun run build"
|
|
187
183
|
},
|
|
188
184
|
"dependencies": {
|
|
@@ -241,7 +237,7 @@
|
|
|
241
237
|
"working-days"
|
|
242
238
|
],
|
|
243
239
|
"license": "MIT",
|
|
244
|
-
"homepage": "https://forgezero.net/docs/runtime",
|
|
240
|
+
"homepage": "https://www.forgezero.net/docs/runtime",
|
|
245
241
|
"repository": {
|
|
246
242
|
"type": "git",
|
|
247
243
|
"url": "git+https://github.com/axxra/forgezero.git",
|