stitchkit 0.77.0 → 0.78.0
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/agent-runtime-coding-tools.js +1 -1
- package/dist/agent-runtime-harness.js +6 -6
- package/dist/agent-runtime.js +11 -11
- package/dist/application/decisions.d.ts +28 -25
- package/dist/application/decisions.d.ts.map +1 -1
- package/dist/application/kernel.d.ts +2 -0
- package/dist/application/kernel.d.ts.map +1 -1
- package/dist/application/keyspace.d.ts +11 -1
- package/dist/application/keyspace.d.ts.map +1 -1
- package/dist/application/schedule.d.ts.map +1 -1
- package/dist/application/server-resource.d.ts.map +1 -1
- package/dist/application-diagnostic-journal.d.ts +21 -0
- package/dist/application-diagnostic-journal.d.ts.map +1 -0
- package/dist/application-diagnostic-journal.js +714 -0
- package/dist/application-opentelemetry.js +92 -5
- package/dist/application-schemas.js +13 -98
- package/dist/application.d.ts +1 -2
- package/dist/application.d.ts.map +1 -1
- package/dist/application.js +1775 -858
- package/dist/cli.js +7 -7
- package/dist/contract/index.js +9 -7
- package/dist/files/boundary.d.ts.map +1 -1
- package/dist/files.js +1 -1
- package/dist/{index-3z73fh2c.js → index-1ckavx4h.js} +49 -1
- package/dist/{index-s2rchahr.js → index-31k1ns1s.js} +4 -4
- package/dist/{index-m668wzyc.js → index-4ntp63ps.js} +4 -4
- package/dist/{index-1rxswfbv.js → index-56fm73re.js} +1 -1
- package/dist/{index-ezmn6ac6.js → index-5r0ak8fr.js} +3 -3
- package/dist/{index-f6n5n7nz.js → index-7z69kyrh.js} +10 -87
- package/dist/{index-k2zczx1g.js → index-93xp5tgg.js} +1 -1
- package/dist/{index-bfcpjw20.js → index-actkayzn.js} +7 -3
- package/dist/index-anyqnqcv.js +79 -0
- package/dist/{index-7qy2ex0m.js → index-cwp38wxn.js} +1 -1
- package/dist/{index-35z5h2ty.js → index-dmfn9m4x.js} +125 -4
- package/dist/index-h4pj6fta.js +87 -0
- package/dist/{index-jqtsc9mj.js → index-j6mfaw26.js} +2 -2
- package/dist/{index-58jzmnn4.js → index-k45qqrdh.js} +513 -39
- package/dist/{index-8eywc9zv.js → index-kw12fsbc.js} +0 -1
- package/dist/index-n1g2xm6a.js +203 -0
- package/dist/{index-t8qyvrvg.js → index-n7apa6sy.js} +12 -12
- package/dist/{index-s4c8wy8m.js → index-qaajjfp6.js} +8 -8
- package/dist/{index-nemjkxjp.js → index-s5tn4kdv.js} +14 -10
- package/dist/index-wfq8zerm.js +92 -0
- package/dist/index.js +53 -1215
- package/dist/internal/deadline.d.ts +17 -0
- package/dist/internal/deadline.d.ts.map +1 -0
- package/dist/live.js +12 -89
- package/dist/node.js +5 -6
- package/dist/observability/context.d.ts.map +1 -1
- package/dist/observability/index.js +3 -3
- package/dist/primitives.js +2 -1
- package/dist/remote.js +3 -6
- package/dist/server/event-bus.d.ts.map +1 -1
- package/dist/server/index.js +31 -30
- package/dist/testing.js +1562 -87
- package/dist/tool-invoker.js +6 -6
- package/dist/tools.js +66 -23
- package/llms-full.txt +110 -13
- package/package.json +8 -4
- package/dist/index-3cwck0rm.js +0 -897
- package/dist/index-7b188kmz.js +0 -49
- package/dist/index-a916km3r.js +0 -45
- package/dist/index-vc1b0b1b.js +0 -825
- package/dist/{index-da1aqnhb.js → index-aewy3x5j.js} +3 -3
- package/dist/{index-nt1mp8km.js → index-efm026pw.js} +3 -3
package/dist/application.js
CHANGED
|
@@ -1,46 +1,26 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
createBackoff
|
|
3
|
+
} from "./index-h4pj6fta.js";
|
|
4
|
+
import {
|
|
5
|
+
argumentsDigest,
|
|
6
|
+
stableValue,
|
|
7
|
+
watchKeyString
|
|
8
|
+
} from "./index-wfq8zerm.js";
|
|
9
|
+
import {
|
|
9
10
|
BoundedChannelPolicySchema,
|
|
10
11
|
BoundedChannelReaderError,
|
|
11
12
|
BoundedChannelSnapshotSchema,
|
|
12
13
|
BoundedChannelStateSchema,
|
|
13
14
|
CreditWindowSnapshotSchema,
|
|
14
|
-
ManagedScheduleDescriptorSchema,
|
|
15
|
-
ManagedScheduleErrorPolicySchema,
|
|
16
|
-
ManagedScheduleOverlapSchema,
|
|
17
|
-
ManagedScheduleStatusSchema,
|
|
18
|
-
createActivityProjection,
|
|
19
|
-
createApplicationSnapshotSink,
|
|
20
15
|
createBoundedChannel,
|
|
21
|
-
createCreditWindow
|
|
22
|
-
|
|
23
|
-
} from "./index-3cwck0rm.js";
|
|
16
|
+
createCreditWindow
|
|
17
|
+
} from "./index-dmfn9m4x.js";
|
|
24
18
|
import {
|
|
25
|
-
|
|
26
|
-
} from "./index-
|
|
19
|
+
AppError
|
|
20
|
+
} from "./index-0w9abg87.js";
|
|
27
21
|
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
} from "./index-a916km3r.js";
|
|
31
|
-
import {
|
|
32
|
-
ApplicationAdmissionError,
|
|
33
|
-
ApplicationRestartInputSchema,
|
|
34
|
-
ApplicationRestartOutcomeSchema,
|
|
35
|
-
ApplicationRestartResultSchema,
|
|
36
|
-
ApplicationShutdownBudgetSchema,
|
|
37
|
-
ApplicationShutdownOptionsSchema,
|
|
38
|
-
createApplication
|
|
39
|
-
} from "./index-vc1b0b1b.js";
|
|
40
|
-
import {
|
|
41
|
-
defineManagedResource,
|
|
42
|
-
managedResourceDependencyId
|
|
43
|
-
} from "./index-2k4yrqkc.js";
|
|
22
|
+
isRecord
|
|
23
|
+
} from "./index-ksp6e2ye.js";
|
|
44
24
|
import {
|
|
45
25
|
ApplicationAdmissionSnapshotSchema,
|
|
46
26
|
ApplicationHealthSchema,
|
|
@@ -53,46 +33,315 @@ import {
|
|
|
53
33
|
ManagedResourceSnapshotSchema,
|
|
54
34
|
ManagedResourceStateSchema,
|
|
55
35
|
projectApplicationStatus
|
|
56
|
-
} from "./index-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
import {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
// src/application/admission.ts
|
|
36
|
+
} from "./index-kw12fsbc.js";
|
|
37
|
+
|
|
38
|
+
// src/application/activity.ts
|
|
39
|
+
import { z as z2 } from "zod";
|
|
40
|
+
|
|
41
|
+
// src/application/latest-sink.ts
|
|
63
42
|
import { z } from "zod";
|
|
64
|
-
var
|
|
65
|
-
var
|
|
43
|
+
var SnapshotRevisionSchema = z.number().int().nonnegative();
|
|
44
|
+
var ApplicationSnapshotSinkStatusSchema = z.object({
|
|
45
|
+
accepting: z.boolean(),
|
|
46
|
+
received: z.number().int().nonnegative(),
|
|
47
|
+
accepted: z.number().int().nonnegative(),
|
|
48
|
+
rejected: z.number().int().nonnegative(),
|
|
49
|
+
delivered: z.number().int().nonnegative(),
|
|
50
|
+
coalesced: z.number().int().nonnegative(),
|
|
51
|
+
failed: z.number().int().nonnegative(),
|
|
52
|
+
inFlight: z.boolean(),
|
|
53
|
+
pending: z.boolean(),
|
|
54
|
+
lastAcceptedRevision: SnapshotRevisionSchema.optional(),
|
|
55
|
+
lastDeliveredRevision: SnapshotRevisionSchema.optional()
|
|
56
|
+
}).strict().readonly();
|
|
57
|
+
function createApplicationSnapshotSink(config) {
|
|
58
|
+
let accepting = true;
|
|
59
|
+
let received = 0;
|
|
60
|
+
let accepted = 0;
|
|
61
|
+
let rejected = 0;
|
|
62
|
+
let delivered = 0;
|
|
63
|
+
let coalesced = 0;
|
|
64
|
+
let failed = 0;
|
|
65
|
+
let lastAcceptedRevision;
|
|
66
|
+
let lastDeliveredRevision;
|
|
67
|
+
let inFlight;
|
|
68
|
+
let closePromise;
|
|
69
|
+
let resolveClose;
|
|
70
|
+
const pending = createBoundedChannel({
|
|
71
|
+
policy: "latest",
|
|
72
|
+
maxItems: 1,
|
|
73
|
+
maxBytes: 1,
|
|
74
|
+
sizeOf: () => 1
|
|
75
|
+
});
|
|
76
|
+
const getStatus = () => ApplicationSnapshotSinkStatusSchema.parse({
|
|
77
|
+
accepting,
|
|
78
|
+
received,
|
|
79
|
+
accepted,
|
|
80
|
+
rejected,
|
|
81
|
+
delivered,
|
|
82
|
+
coalesced,
|
|
83
|
+
failed,
|
|
84
|
+
inFlight: inFlight !== undefined,
|
|
85
|
+
pending: pending.getSnapshot().queuedItems > 0,
|
|
86
|
+
...lastAcceptedRevision !== undefined && { lastAcceptedRevision },
|
|
87
|
+
...lastDeliveredRevision !== undefined && { lastDeliveredRevision }
|
|
88
|
+
});
|
|
89
|
+
const reportFailure = (error, snapshot) => {
|
|
90
|
+
if (!config.onSinkError)
|
|
91
|
+
return;
|
|
92
|
+
Promise.resolve().then(() => config.onSinkError?.({ error, snapshot })).catch(() => {
|
|
93
|
+
return;
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
const settleCloseIfIdle = () => {
|
|
97
|
+
if (accepting || inFlight || pending.getSnapshot().queuedItems > 0 || !resolveClose)
|
|
98
|
+
return;
|
|
99
|
+
const resolve = resolveClose;
|
|
100
|
+
resolveClose = undefined;
|
|
101
|
+
resolve(getStatus());
|
|
102
|
+
};
|
|
103
|
+
const startWrite = (snapshot) => {
|
|
104
|
+
const write = Promise.resolve().then(() => config.write(snapshot)).then(() => {
|
|
105
|
+
delivered += 1;
|
|
106
|
+
lastDeliveredRevision = snapshot.revision;
|
|
107
|
+
}).catch((error) => {
|
|
108
|
+
failed += 1;
|
|
109
|
+
reportFailure(error, snapshot);
|
|
110
|
+
}).finally(() => {
|
|
111
|
+
if (pending.getSnapshot().queuedItems > 0) {
|
|
112
|
+
pending.next().then((next) => {
|
|
113
|
+
if (next.done) {
|
|
114
|
+
inFlight = undefined;
|
|
115
|
+
settleCloseIfIdle();
|
|
116
|
+
} else {
|
|
117
|
+
startWrite(next.value);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
} else {
|
|
121
|
+
inFlight = undefined;
|
|
122
|
+
settleCloseIfIdle();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
inFlight = write;
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
publish(snapshot) {
|
|
129
|
+
received += 1;
|
|
130
|
+
const revision = SnapshotRevisionSchema.parse(snapshot.revision);
|
|
131
|
+
if (!accepting || lastAcceptedRevision !== undefined && revision <= lastAcceptedRevision) {
|
|
132
|
+
rejected += 1;
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
accepted += 1;
|
|
136
|
+
lastAcceptedRevision = revision;
|
|
137
|
+
if (!inFlight) {
|
|
138
|
+
startWrite(snapshot);
|
|
139
|
+
} else {
|
|
140
|
+
const offered = pending.offer(snapshot);
|
|
141
|
+
if (offered.outcome === "coalesced")
|
|
142
|
+
coalesced += offered.replaced;
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
},
|
|
146
|
+
getStatus,
|
|
147
|
+
close() {
|
|
148
|
+
if (closePromise)
|
|
149
|
+
return closePromise;
|
|
150
|
+
accepting = false;
|
|
151
|
+
pending.close({ mode: "drain" });
|
|
152
|
+
closePromise = new Promise((resolve) => {
|
|
153
|
+
resolveClose = resolve;
|
|
154
|
+
settleCloseIfIdle();
|
|
155
|
+
});
|
|
156
|
+
return closePromise;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/application/activity.ts
|
|
162
|
+
var BoundedOperationalIdSchema = z2.string().min(1).max(64).regex(/^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/);
|
|
163
|
+
var NonNegativeIntegerSchema = z2.number().int().nonnegative();
|
|
164
|
+
var ActivityIdSchema = BoundedOperationalIdSchema;
|
|
165
|
+
var ActivityStageIdSchema = BoundedOperationalIdSchema;
|
|
166
|
+
var ActivityStageSnapshotSchema = z2.object({
|
|
167
|
+
id: ActivityStageIdSchema,
|
|
168
|
+
active: NonNegativeIntegerSchema,
|
|
169
|
+
queued: NonNegativeIntegerSchema,
|
|
170
|
+
completed: NonNegativeIntegerSchema,
|
|
171
|
+
failed: NonNegativeIntegerSchema
|
|
172
|
+
}).strict().readonly();
|
|
173
|
+
var ActivityTotalsSchema = z2.object({
|
|
174
|
+
active: NonNegativeIntegerSchema,
|
|
175
|
+
queued: NonNegativeIntegerSchema,
|
|
176
|
+
completed: NonNegativeIntegerSchema,
|
|
177
|
+
failed: NonNegativeIntegerSchema
|
|
178
|
+
}).strict().readonly();
|
|
179
|
+
var ActivitySnapshotSchema = z2.object({
|
|
180
|
+
id: ActivityIdSchema,
|
|
181
|
+
epoch: z2.string().uuid(),
|
|
182
|
+
revision: NonNegativeIntegerSchema,
|
|
183
|
+
capturedAt: z2.string().datetime({ offset: true }),
|
|
184
|
+
changedAt: z2.string().datetime({ offset: true }),
|
|
185
|
+
stages: z2.array(ActivityStageSnapshotSchema).min(1).max(64).readonly(),
|
|
186
|
+
totals: ActivityTotalsSchema
|
|
187
|
+
}).strict().readonly();
|
|
188
|
+
var ActivityLiveStateSchema = z2.enum(["active", "queued"]);
|
|
189
|
+
var ActivityTokenBrand = Symbol("stitchkit.application.activity");
|
|
190
|
+
function emptyCounters() {
|
|
191
|
+
return { active: 0, queued: 0, completed: 0, failed: 0 };
|
|
192
|
+
}
|
|
193
|
+
function totalsOf(stages) {
|
|
194
|
+
return stages.reduce((totals, stage) => ({
|
|
195
|
+
active: totals.active + stage.active,
|
|
196
|
+
queued: totals.queued + stage.queued,
|
|
197
|
+
completed: totals.completed + stage.completed,
|
|
198
|
+
failed: totals.failed + stage.failed
|
|
199
|
+
}), emptyCounters());
|
|
200
|
+
}
|
|
201
|
+
function createActivityProjection(config) {
|
|
202
|
+
const id = ActivityIdSchema.parse(config.id);
|
|
203
|
+
const declaredStages = z2.array(ActivityStageIdSchema).min(1).max(64).parse(config.stages);
|
|
204
|
+
const uniqueStages = new Set(declaredStages);
|
|
205
|
+
if (uniqueStages.size !== declaredStages.length) {
|
|
206
|
+
throw new Error("[stitchkit] createActivityProjection: stage ids must be unique");
|
|
207
|
+
}
|
|
208
|
+
const epoch = z2.string().uuid().parse(config.epoch ?? crypto.randomUUID());
|
|
209
|
+
const now = config.now ?? (() => new Date);
|
|
210
|
+
const counters = new Map(declaredStages.map((stage) => [stage, emptyCounters()]));
|
|
211
|
+
const activities = new WeakMap;
|
|
212
|
+
const subscribers = new Set;
|
|
213
|
+
let revision = 0;
|
|
214
|
+
let changedAt = now().toISOString();
|
|
215
|
+
const countersFor = (stage) => {
|
|
216
|
+
const value = counters.get(stage);
|
|
217
|
+
if (!value) {
|
|
218
|
+
throw new Error(`[stitchkit] createActivityProjection: undeclared stage "${stage}"`);
|
|
219
|
+
}
|
|
220
|
+
return value;
|
|
221
|
+
};
|
|
222
|
+
const getSnapshot = () => {
|
|
223
|
+
const stages = declaredStages.map((stage) => ({ id: stage, ...countersFor(stage) }));
|
|
224
|
+
return ActivitySnapshotSchema.parse({
|
|
225
|
+
id,
|
|
226
|
+
epoch,
|
|
227
|
+
revision,
|
|
228
|
+
capturedAt: now().toISOString(),
|
|
229
|
+
changedAt,
|
|
230
|
+
stages,
|
|
231
|
+
totals: totalsOf(stages)
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
const publishChange = () => {
|
|
235
|
+
revision += 1;
|
|
236
|
+
changedAt = now().toISOString();
|
|
237
|
+
const snapshot = getSnapshot();
|
|
238
|
+
for (const subscriber of subscribers)
|
|
239
|
+
subscriber.publish(snapshot);
|
|
240
|
+
};
|
|
241
|
+
const requireActivity = (token) => {
|
|
242
|
+
const activity = activities.get(token);
|
|
243
|
+
if (!activity) {
|
|
244
|
+
throw new Error("[stitchkit] activity token belongs to another projection");
|
|
245
|
+
}
|
|
246
|
+
return activity;
|
|
247
|
+
};
|
|
248
|
+
const leaveLiveState = (activity) => {
|
|
249
|
+
if (activity.state === "active" || activity.state === "queued") {
|
|
250
|
+
countersFor(activity.stage)[activity.state] -= 1;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
const settle = (token, terminal) => {
|
|
254
|
+
const activity = requireActivity(token);
|
|
255
|
+
if (activity.state === "completed" || activity.state === "failed")
|
|
256
|
+
return false;
|
|
257
|
+
leaveLiveState(activity);
|
|
258
|
+
countersFor(activity.stage)[terminal] += 1;
|
|
259
|
+
activity.state = terminal;
|
|
260
|
+
publishChange();
|
|
261
|
+
return true;
|
|
262
|
+
};
|
|
263
|
+
return {
|
|
264
|
+
open(stage, rawState = "active") {
|
|
265
|
+
const parsedStage = ActivityStageIdSchema.parse(stage);
|
|
266
|
+
const state = ActivityLiveStateSchema.parse(rawState);
|
|
267
|
+
countersFor(parsedStage)[state] += 1;
|
|
268
|
+
const token = Object.freeze({
|
|
269
|
+
get [ActivityTokenBrand]() {
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
activities.set(token, { stage: parsedStage, state });
|
|
274
|
+
publishChange();
|
|
275
|
+
return token;
|
|
276
|
+
},
|
|
277
|
+
transition(token, input) {
|
|
278
|
+
const activity = requireActivity(token);
|
|
279
|
+
if (activity.state === "completed" || activity.state === "failed") {
|
|
280
|
+
throw new Error("[stitchkit] cannot transition a terminal activity");
|
|
281
|
+
}
|
|
282
|
+
const stage = ActivityStageIdSchema.parse(input.stage);
|
|
283
|
+
const state = ActivityLiveStateSchema.parse(input.state);
|
|
284
|
+
countersFor(stage);
|
|
285
|
+
if (activity.stage === stage && activity.state === state)
|
|
286
|
+
return false;
|
|
287
|
+
leaveLiveState(activity);
|
|
288
|
+
countersFor(stage)[state] += 1;
|
|
289
|
+
activity.stage = stage;
|
|
290
|
+
activity.state = state;
|
|
291
|
+
publishChange();
|
|
292
|
+
return true;
|
|
293
|
+
},
|
|
294
|
+
complete: (token) => settle(token, "completed"),
|
|
295
|
+
fail: (token) => settle(token, "failed"),
|
|
296
|
+
getSnapshot,
|
|
297
|
+
subscribe(listener) {
|
|
298
|
+
const sink = createApplicationSnapshotSink({
|
|
299
|
+
write: listener,
|
|
300
|
+
...config.onSubscriberError && { onSinkError: config.onSubscriberError }
|
|
301
|
+
});
|
|
302
|
+
subscribers.add(sink);
|
|
303
|
+
sink.publish(getSnapshot());
|
|
304
|
+
return () => {
|
|
305
|
+
subscribers.delete(sink);
|
|
306
|
+
sink.close();
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
// src/application/admission.ts
|
|
312
|
+
import { z as z3 } from "zod";
|
|
313
|
+
var PositiveSafeIntegerSchema = z3.number().int().positive().safe();
|
|
314
|
+
var BoundedRateBudgetSchema = z3.object({
|
|
66
315
|
limit: PositiveSafeIntegerSchema,
|
|
67
316
|
intervalMs: PositiveSafeIntegerSchema
|
|
68
317
|
}).strict().readonly();
|
|
69
|
-
var BoundedAdmissionPerKeyLimitsSchema =
|
|
318
|
+
var BoundedAdmissionPerKeyLimitsSchema = z3.object({
|
|
70
319
|
maxConcurrent: PositiveSafeIntegerSchema,
|
|
71
320
|
rate: BoundedRateBudgetSchema.optional()
|
|
72
321
|
}).strict().readonly();
|
|
73
|
-
var PerKeyLimitResolverSchema =
|
|
74
|
-
var BoundedAdmissionPolicySchema =
|
|
75
|
-
global:
|
|
322
|
+
var PerKeyLimitResolverSchema = z3.custom((value) => typeof value === "function", { message: "perKey.limits must be a function of the key" });
|
|
323
|
+
var BoundedAdmissionPolicySchema = z3.object({
|
|
324
|
+
global: z3.object({
|
|
76
325
|
maxConcurrent: PositiveSafeIntegerSchema,
|
|
77
326
|
rate: BoundedRateBudgetSchema.optional()
|
|
78
327
|
}).strict(),
|
|
79
|
-
perKey:
|
|
80
|
-
|
|
328
|
+
perKey: z3.union([
|
|
329
|
+
z3.object({
|
|
81
330
|
maxConcurrent: PositiveSafeIntegerSchema,
|
|
82
331
|
maxKeys: PositiveSafeIntegerSchema,
|
|
83
332
|
rate: BoundedRateBudgetSchema.optional(),
|
|
84
|
-
limits:
|
|
333
|
+
limits: z3.never().optional()
|
|
85
334
|
}).strict(),
|
|
86
|
-
|
|
335
|
+
z3.object({
|
|
87
336
|
maxKeys: PositiveSafeIntegerSchema,
|
|
88
337
|
limits: PerKeyLimitResolverSchema,
|
|
89
|
-
maxConcurrent:
|
|
90
|
-
rate:
|
|
338
|
+
maxConcurrent: z3.never().optional(),
|
|
339
|
+
rate: z3.never().optional()
|
|
91
340
|
}).strict()
|
|
92
341
|
]).optional()
|
|
93
342
|
}).strict().readonly();
|
|
94
|
-
var BoundedAdmissionStateSchema =
|
|
95
|
-
var BoundedAdmissionRefusalReasonSchema =
|
|
343
|
+
var BoundedAdmissionStateSchema = z3.enum(["accepting", "draining", "closed"]);
|
|
344
|
+
var BoundedAdmissionRefusalReasonSchema = z3.enum([
|
|
96
345
|
"not-accepting",
|
|
97
346
|
"global-concurrency",
|
|
98
347
|
"key-required",
|
|
@@ -102,25 +351,25 @@ var BoundedAdmissionRefusalReasonSchema = z.enum([
|
|
|
102
351
|
"key-rate",
|
|
103
352
|
"upstream"
|
|
104
353
|
]);
|
|
105
|
-
var RefusalCountersSchema =
|
|
106
|
-
"not-accepting":
|
|
107
|
-
"global-concurrency":
|
|
108
|
-
"key-required":
|
|
109
|
-
"key-capacity":
|
|
110
|
-
"key-concurrency":
|
|
111
|
-
"global-rate":
|
|
112
|
-
"key-rate":
|
|
113
|
-
upstream:
|
|
354
|
+
var RefusalCountersSchema = z3.object({
|
|
355
|
+
"not-accepting": z3.number().int().nonnegative(),
|
|
356
|
+
"global-concurrency": z3.number().int().nonnegative(),
|
|
357
|
+
"key-required": z3.number().int().nonnegative(),
|
|
358
|
+
"key-capacity": z3.number().int().nonnegative(),
|
|
359
|
+
"key-concurrency": z3.number().int().nonnegative(),
|
|
360
|
+
"global-rate": z3.number().int().nonnegative(),
|
|
361
|
+
"key-rate": z3.number().int().nonnegative(),
|
|
362
|
+
upstream: z3.number().int().nonnegative()
|
|
114
363
|
}).strict().readonly();
|
|
115
|
-
var BoundedAdmissionSnapshotSchema =
|
|
364
|
+
var BoundedAdmissionSnapshotSchema = z3.object({
|
|
116
365
|
state: BoundedAdmissionStateSchema,
|
|
117
|
-
active:
|
|
118
|
-
accepted:
|
|
119
|
-
released:
|
|
120
|
-
refused:
|
|
121
|
-
keys:
|
|
122
|
-
globalRateSamples:
|
|
123
|
-
keyRateSamples:
|
|
366
|
+
active: z3.number().int().nonnegative(),
|
|
367
|
+
accepted: z3.number().int().nonnegative(),
|
|
368
|
+
released: z3.number().int().nonnegative(),
|
|
369
|
+
refused: z3.number().int().nonnegative(),
|
|
370
|
+
keys: z3.number().int().nonnegative(),
|
|
371
|
+
globalRateSamples: z3.number().int().nonnegative(),
|
|
372
|
+
keyRateSamples: z3.number().int().nonnegative(),
|
|
124
373
|
refusals: RefusalCountersSchema
|
|
125
374
|
}).strict().readonly();
|
|
126
375
|
|
|
@@ -433,12 +682,31 @@ function createBoundedAdmission(config) {
|
|
|
433
682
|
getSnapshot
|
|
434
683
|
};
|
|
435
684
|
}
|
|
685
|
+
// src/internal/deadline.ts
|
|
686
|
+
async function withDeadline(value, timeoutMs) {
|
|
687
|
+
if (!(value instanceof Promise))
|
|
688
|
+
return { settled: true, value };
|
|
689
|
+
let timer;
|
|
690
|
+
const expiry = new Promise((resolve) => {
|
|
691
|
+
timer = setTimeout(() => resolve({ settled: false }), timeoutMs);
|
|
692
|
+
});
|
|
693
|
+
try {
|
|
694
|
+
return await Promise.race([
|
|
695
|
+
value.then((settledValue) => ({ settled: true, value: settledValue })),
|
|
696
|
+
expiry
|
|
697
|
+
]);
|
|
698
|
+
} finally {
|
|
699
|
+
if (timer !== undefined)
|
|
700
|
+
clearTimeout(timer);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
436
704
|
// src/internal/decision.ts
|
|
437
|
-
import { z as
|
|
438
|
-
var PolicyDecisionSchema =
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
705
|
+
import { z as z4 } from "zod";
|
|
706
|
+
var PolicyDecisionSchema = z4.discriminatedUnion("outcome", [
|
|
707
|
+
z4.object({ outcome: z4.literal("allow") }).strict(),
|
|
708
|
+
z4.object({ outcome: z4.literal("deny"), reason: z4.string().min(1) }).strict(),
|
|
709
|
+
z4.object({ outcome: z4.literal("defer") }).strict()
|
|
442
710
|
]);
|
|
443
711
|
|
|
444
712
|
// src/application/decisions.ts
|
|
@@ -454,13 +722,19 @@ class DecisionUndecidedError extends Error {
|
|
|
454
722
|
|
|
455
723
|
class DecisionPolicyError extends Error {
|
|
456
724
|
policyId;
|
|
457
|
-
|
|
458
|
-
|
|
725
|
+
trace;
|
|
726
|
+
constructor(policyId, detail, trace = []) {
|
|
727
|
+
super(`Policy "${policyId}" did not answer (${detail}). A policy returns { outcome: "allow" | "deny" | "defer" } within its deadline, and a deny carries a reason.`);
|
|
459
728
|
this.name = "DecisionPolicyError";
|
|
460
729
|
this.policyId = policyId;
|
|
730
|
+
this.trace = trace;
|
|
461
731
|
}
|
|
462
732
|
}
|
|
463
|
-
function createDecisionPipeline(policies) {
|
|
733
|
+
function createDecisionPipeline(policies, config) {
|
|
734
|
+
const { policyTimeoutMs } = config;
|
|
735
|
+
if (!Number.isInteger(policyTimeoutMs) || policyTimeoutMs <= 0) {
|
|
736
|
+
throw new Error("[stitchkit] decision pipeline: policyTimeoutMs must be a positive integer number of milliseconds.");
|
|
737
|
+
}
|
|
464
738
|
const seen = new Set;
|
|
465
739
|
for (const policy of policies) {
|
|
466
740
|
if (policy.id.trim() === "") {
|
|
@@ -476,10 +750,21 @@ function createDecisionPipeline(policies) {
|
|
|
476
750
|
async decide(input) {
|
|
477
751
|
const trace = [];
|
|
478
752
|
for (const policy of policies) {
|
|
479
|
-
|
|
753
|
+
let answer;
|
|
754
|
+
try {
|
|
755
|
+
const outcome = await withDeadline(policy.decide(input), policyTimeoutMs);
|
|
756
|
+
if (!outcome.settled) {
|
|
757
|
+
throw new DecisionPolicyError(policy.id, `did not answer within ${policyTimeoutMs}ms`, trace);
|
|
758
|
+
}
|
|
759
|
+
answer = outcome.value;
|
|
760
|
+
} catch (error) {
|
|
761
|
+
if (error instanceof DecisionPolicyError)
|
|
762
|
+
throw error;
|
|
763
|
+
throw new DecisionPolicyError(policy.id, error instanceof Error ? `threw: ${error.message}` : "threw", trace);
|
|
764
|
+
}
|
|
480
765
|
const parsed = PolicyDecisionSchema.safeParse(answer);
|
|
481
766
|
if (!parsed.success) {
|
|
482
|
-
throw new DecisionPolicyError(policy.id, parsed.error.issues[0]?.message ?? "invalid");
|
|
767
|
+
throw new DecisionPolicyError(policy.id, parsed.error.issues[0]?.message ?? "invalid", trace);
|
|
483
768
|
}
|
|
484
769
|
const decision = parsed.data;
|
|
485
770
|
trace.push(decision.outcome === "deny" ? { id: policy.id, outcome: "deny", reason: decision.reason } : { id: policy.id, outcome: decision.outcome });
|
|
@@ -494,23 +779,20 @@ function createDecisionPipeline(policies) {
|
|
|
494
779
|
}
|
|
495
780
|
};
|
|
496
781
|
}
|
|
497
|
-
// src/application/diagnostic-journal.ts
|
|
498
|
-
import { randomUUID } from "node:crypto";
|
|
499
|
-
|
|
500
782
|
// src/application/diagnostic-journal-contract.ts
|
|
501
|
-
import { z as
|
|
502
|
-
var PositiveSafeIntegerSchema2 =
|
|
503
|
-
var FileModeSchema =
|
|
504
|
-
var DiagnosticJournalLimitsSchema =
|
|
783
|
+
import { z as z5 } from "zod";
|
|
784
|
+
var PositiveSafeIntegerSchema2 = z5.number().int().positive().safe();
|
|
785
|
+
var FileModeSchema = z5.number().int().min(0).max(511);
|
|
786
|
+
var DiagnosticJournalLimitsSchema = z5.object({
|
|
505
787
|
maxEventBytes: PositiveSafeIntegerSchema2,
|
|
506
788
|
maxPendingItems: PositiveSafeIntegerSchema2,
|
|
507
789
|
maxPendingBytes: PositiveSafeIntegerSchema2,
|
|
508
790
|
maxFileBytes: PositiveSafeIntegerSchema2,
|
|
509
791
|
maxFiles: PositiveSafeIntegerSchema2
|
|
510
792
|
}).strict().readonly();
|
|
511
|
-
var DiagnosticJournalLockPolicySchema =
|
|
512
|
-
var DiagnosticJournalStateSchema =
|
|
513
|
-
var DiagnosticJournalRefusalReasonSchema =
|
|
793
|
+
var DiagnosticJournalLockPolicySchema = z5.enum(["refuse", "reclaim-stale"]);
|
|
794
|
+
var DiagnosticJournalStateSchema = z5.enum(["open", "draining", "closed", "failed"]);
|
|
795
|
+
var DiagnosticJournalRefusalReasonSchema = z5.enum([
|
|
514
796
|
"closed",
|
|
515
797
|
"failed",
|
|
516
798
|
"invalid",
|
|
@@ -518,704 +800,228 @@ var DiagnosticJournalRefusalReasonSchema = z3.enum([
|
|
|
518
800
|
"item-capacity",
|
|
519
801
|
"byte-capacity"
|
|
520
802
|
]);
|
|
521
|
-
var DiagnosticJournalRefusalCountersSchema =
|
|
522
|
-
closed:
|
|
523
|
-
failed:
|
|
524
|
-
invalid:
|
|
525
|
-
oversized:
|
|
526
|
-
"item-capacity":
|
|
527
|
-
"byte-capacity":
|
|
803
|
+
var DiagnosticJournalRefusalCountersSchema = z5.object({
|
|
804
|
+
closed: z5.number().int().nonnegative(),
|
|
805
|
+
failed: z5.number().int().nonnegative(),
|
|
806
|
+
invalid: z5.number().int().nonnegative(),
|
|
807
|
+
oversized: z5.number().int().nonnegative(),
|
|
808
|
+
"item-capacity": z5.number().int().nonnegative(),
|
|
809
|
+
"byte-capacity": z5.number().int().nonnegative()
|
|
528
810
|
}).strict().readonly();
|
|
529
|
-
var DiagnosticJournalFailurePhaseSchema =
|
|
530
|
-
var DiagnosticJournalStatusSchema =
|
|
811
|
+
var DiagnosticJournalFailurePhaseSchema = z5.enum(["write", "rotation", "close"]);
|
|
812
|
+
var DiagnosticJournalStatusSchema = z5.object({
|
|
531
813
|
state: DiagnosticJournalStateSchema,
|
|
532
|
-
epoch:
|
|
814
|
+
epoch: z5.uuid(),
|
|
533
815
|
limits: DiagnosticJournalLimitsSchema,
|
|
534
|
-
lock:
|
|
816
|
+
lock: z5.object({
|
|
535
817
|
policy: DiagnosticJournalLockPolicySchema,
|
|
536
|
-
reclaimedStale:
|
|
818
|
+
reclaimedStale: z5.boolean()
|
|
537
819
|
}).strict().readonly(),
|
|
538
|
-
received:
|
|
539
|
-
accepted:
|
|
540
|
-
refused:
|
|
820
|
+
received: z5.number().int().nonnegative(),
|
|
821
|
+
accepted: z5.number().int().nonnegative(),
|
|
822
|
+
refused: z5.number().int().nonnegative(),
|
|
541
823
|
refusals: DiagnosticJournalRefusalCountersSchema,
|
|
542
|
-
written:
|
|
543
|
-
failedRecords:
|
|
544
|
-
pendingItems:
|
|
545
|
-
pendingBytes:
|
|
546
|
-
inFlight:
|
|
547
|
-
rotations:
|
|
548
|
-
rotationFailures:
|
|
549
|
-
partialTails:
|
|
550
|
-
currentFileBytes:
|
|
551
|
-
retainedFiles:
|
|
552
|
-
lastAcceptedSequence:
|
|
553
|
-
lastWrittenSequence:
|
|
554
|
-
lastSettledSequence:
|
|
555
|
-
lastFailure:
|
|
824
|
+
written: z5.number().int().nonnegative(),
|
|
825
|
+
failedRecords: z5.number().int().nonnegative(),
|
|
826
|
+
pendingItems: z5.number().int().nonnegative(),
|
|
827
|
+
pendingBytes: z5.number().int().nonnegative(),
|
|
828
|
+
inFlight: z5.boolean(),
|
|
829
|
+
rotations: z5.number().int().nonnegative(),
|
|
830
|
+
rotationFailures: z5.number().int().nonnegative(),
|
|
831
|
+
partialTails: z5.number().int().nonnegative(),
|
|
832
|
+
currentFileBytes: z5.number().int().nonnegative(),
|
|
833
|
+
retainedFiles: z5.number().int().nonnegative(),
|
|
834
|
+
lastAcceptedSequence: z5.number().int().positive().optional(),
|
|
835
|
+
lastWrittenSequence: z5.number().int().positive().optional(),
|
|
836
|
+
lastSettledSequence: z5.number().int().positive().optional(),
|
|
837
|
+
lastFailure: z5.object({
|
|
556
838
|
phase: DiagnosticJournalFailurePhaseSchema,
|
|
557
|
-
sequence:
|
|
839
|
+
sequence: z5.number().int().positive().optional()
|
|
558
840
|
}).strict().readonly().optional()
|
|
559
841
|
}).strict().readonly();
|
|
560
|
-
var DiagnosticJournalSubmitResultSchema =
|
|
561
|
-
|
|
562
|
-
outcome:
|
|
563
|
-
epoch:
|
|
564
|
-
sequence:
|
|
842
|
+
var DiagnosticJournalSubmitResultSchema = z5.discriminatedUnion("outcome", [
|
|
843
|
+
z5.object({
|
|
844
|
+
outcome: z5.literal("accepted"),
|
|
845
|
+
epoch: z5.uuid(),
|
|
846
|
+
sequence: z5.number().int().positive()
|
|
565
847
|
}).strict().readonly(),
|
|
566
|
-
|
|
567
|
-
outcome:
|
|
848
|
+
z5.object({
|
|
849
|
+
outcome: z5.literal("refused"),
|
|
568
850
|
reason: DiagnosticJournalRefusalReasonSchema
|
|
569
851
|
}).strict().readonly()
|
|
570
852
|
]);
|
|
571
|
-
var DiagnosticJournalWaitResultSchema =
|
|
572
|
-
outcome:
|
|
853
|
+
var DiagnosticJournalWaitResultSchema = z5.object({
|
|
854
|
+
outcome: z5.enum(["settled", "timed-out", "cancelled"]),
|
|
573
855
|
state: DiagnosticJournalStateSchema,
|
|
574
|
-
throughSequence:
|
|
575
|
-
settledSequence:
|
|
856
|
+
throughSequence: z5.number().int().nonnegative(),
|
|
857
|
+
settledSequence: z5.number().int().nonnegative()
|
|
576
858
|
}).strict().readonly();
|
|
577
|
-
var DiagnosticJournalCloseResultSchema =
|
|
578
|
-
outcome:
|
|
859
|
+
var DiagnosticJournalCloseResultSchema = z5.object({
|
|
860
|
+
outcome: z5.enum(["closed", "timed-out", "cancelled"]),
|
|
579
861
|
state: DiagnosticJournalStateSchema,
|
|
580
|
-
pendingItems:
|
|
862
|
+
pendingItems: z5.number().int().nonnegative()
|
|
581
863
|
}).strict().readonly();
|
|
582
|
-
var DiagnosticJournalFrameSchema =
|
|
583
|
-
schemaVersion:
|
|
584
|
-
epoch:
|
|
585
|
-
sequence:
|
|
586
|
-
event:
|
|
864
|
+
var DiagnosticJournalFrameSchema = z5.object({
|
|
865
|
+
schemaVersion: z5.literal(1),
|
|
866
|
+
epoch: z5.uuid(),
|
|
867
|
+
sequence: z5.number().int().positive(),
|
|
868
|
+
event: z5.json()
|
|
587
869
|
}).strict().readonly();
|
|
588
|
-
function parseDiagnosticJournalMode(mode) {
|
|
589
|
-
return FileModeSchema.parse(mode ?? 384);
|
|
590
|
-
}
|
|
591
870
|
function readDiagnosticJournalLockDiagnosis(error) {
|
|
592
871
|
if (!isRecord(error))
|
|
593
872
|
return;
|
|
594
873
|
const diagnosis = error.journalLock;
|
|
595
874
|
return isRecord(diagnosis) ? diagnosis : undefined;
|
|
596
875
|
}
|
|
876
|
+
// src/application/events.ts
|
|
877
|
+
import { z as z7 } from "zod";
|
|
597
878
|
|
|
598
|
-
// src/
|
|
599
|
-
import { z as
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
return;
|
|
624
|
-
const { pid, host, acquiredAt, machine } = parsed;
|
|
625
|
-
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0)
|
|
626
|
-
return;
|
|
627
|
-
if (typeof host !== "string" || host.length === 0)
|
|
628
|
-
return;
|
|
629
|
-
if (typeof acquiredAt !== "string")
|
|
630
|
-
return;
|
|
631
|
-
const identified = typeof machine === "string" && machine.length > 0 ? { machine } : {};
|
|
632
|
-
return { pid, host, acquiredAt, ...identified };
|
|
633
|
-
}
|
|
634
|
-
var detectedIdentity;
|
|
635
|
-
async function firstReadableLine(paths) {
|
|
636
|
-
for (const path of paths) {
|
|
637
|
-
try {
|
|
638
|
-
const text = (await readFile(path, "utf8")).trim();
|
|
639
|
-
if (text.length > 0)
|
|
640
|
-
return text;
|
|
641
|
-
} catch {}
|
|
642
|
-
}
|
|
643
|
-
return null;
|
|
644
|
-
}
|
|
645
|
-
var run = promisify(execFile);
|
|
646
|
-
function parsePlatformUuid(dump) {
|
|
647
|
-
return /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(dump)?.[1] ?? null;
|
|
648
|
-
}
|
|
649
|
-
async function readDarwinPlatformUuid(command = "ioreg") {
|
|
650
|
-
try {
|
|
651
|
-
const { stdout } = await run(command, ["-rd1", "-c", "IOPlatformExpertDevice"], {
|
|
652
|
-
timeout: 2000,
|
|
653
|
-
maxBuffer: 1 << 20,
|
|
654
|
-
encoding: "utf8"
|
|
655
|
-
});
|
|
656
|
-
return parsePlatformUuid(stdout);
|
|
657
|
-
} catch {
|
|
658
|
-
return null;
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
async function machineIdentity(declared) {
|
|
662
|
-
if (declared !== undefined)
|
|
663
|
-
return declared;
|
|
664
|
-
if (detectedIdentity !== undefined)
|
|
665
|
-
return detectedIdentity;
|
|
666
|
-
detectedIdentity = platform() === "darwin" ? await readDarwinPlatformUuid() : await firstReadableLine(["/etc/machine-id", "/var/lib/dbus/machine-id"]);
|
|
667
|
-
return detectedIdentity;
|
|
668
|
-
}
|
|
669
|
-
function attribute(owner, identity) {
|
|
670
|
-
if (identity !== null && owner.machine !== undefined) {
|
|
671
|
-
return owner.machine === identity ? "this-machine" : "another-machine";
|
|
672
|
-
}
|
|
673
|
-
return owner.host === hostname() ? "this-machine" : "unattributable";
|
|
674
|
-
}
|
|
675
|
-
async function probeLiveness(owner) {
|
|
676
|
-
try {
|
|
677
|
-
process.kill(owner.pid, 0);
|
|
678
|
-
} catch (error) {
|
|
679
|
-
return isRecord(error) && error.code === "ESRCH" ? "gone" : "alive";
|
|
680
|
-
}
|
|
681
|
-
return await isZombieProcess(owner.pid) ? "gone" : "alive";
|
|
682
|
-
}
|
|
683
|
-
async function isZombieProcess(pid, seam = {}) {
|
|
684
|
-
const { procRoot = "/proc", psCommand = "ps" } = seam;
|
|
685
|
-
try {
|
|
686
|
-
const stat = await readFile(`${procRoot}/${pid}/stat`, "utf8");
|
|
687
|
-
return stat.slice(stat.lastIndexOf(")") + 1).trimStart().startsWith("Z");
|
|
688
|
-
} catch {}
|
|
689
|
-
try {
|
|
690
|
-
const { stdout } = await run(psCommand, ["-o", "state=", "-p", String(pid)], {
|
|
691
|
-
timeout: 2000,
|
|
692
|
-
maxBuffer: 1 << 16,
|
|
693
|
-
encoding: "utf8"
|
|
694
|
-
});
|
|
695
|
-
return stdout.trim().startsWith("Z");
|
|
696
|
-
} catch {
|
|
697
|
-
return false;
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
async function diagnose(owner, declaredIdentity) {
|
|
701
|
-
if (!owner)
|
|
702
|
-
return { attribution: "unattributable", liveness: "not-probed", owner: null };
|
|
703
|
-
const attribution = attribute(owner, await machineIdentity(declaredIdentity));
|
|
704
|
-
const record = {
|
|
705
|
-
pid: owner.pid,
|
|
706
|
-
host: owner.host,
|
|
707
|
-
acquiredAt: owner.acquiredAt,
|
|
708
|
-
...owner.machine !== undefined && { machine: owner.machine }
|
|
709
|
-
};
|
|
710
|
-
if (attribution !== "this-machine")
|
|
711
|
-
return { attribution, liveness: "not-probed", owner: record };
|
|
712
|
-
return { attribution, liveness: await probeLiveness(owner), owner: record };
|
|
713
|
-
}
|
|
714
|
-
async function readLockOwner(lockPath) {
|
|
715
|
-
let handle;
|
|
716
|
-
try {
|
|
717
|
-
handle = await open(lockPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
718
|
-
return readOwner(await handle.readFile("utf8"));
|
|
719
|
-
} catch {
|
|
720
|
-
return;
|
|
721
|
-
} finally {
|
|
722
|
-
await handle?.close().catch(() => {
|
|
723
|
-
return;
|
|
724
|
-
});
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
async function createExclusive(lockPath, mode, declaredIdentity) {
|
|
728
|
-
const handle = await open(lockPath, "wx", mode);
|
|
729
|
-
const identity = await machineIdentity(declaredIdentity);
|
|
730
|
-
const owner = {
|
|
731
|
-
pid: process.pid,
|
|
732
|
-
host: hostname(),
|
|
733
|
-
acquiredAt: new Date().toISOString(),
|
|
734
|
-
...identity !== null && { machine: identity }
|
|
735
|
-
};
|
|
736
|
-
await handle.writeFile(`${JSON.stringify(owner)}
|
|
737
|
-
`, "utf8");
|
|
738
|
-
return handle;
|
|
739
|
-
}
|
|
740
|
-
async function acquireDiagnosticJournalLock(lockPath, mode, policy, declaredIdentity) {
|
|
741
|
-
try {
|
|
742
|
-
return {
|
|
743
|
-
handle: await createExclusive(lockPath, mode, declaredIdentity),
|
|
744
|
-
reclaimedStale: false
|
|
745
|
-
};
|
|
746
|
-
} catch (error) {
|
|
747
|
-
if (policy !== "reclaim-stale" || !isExisting(error))
|
|
748
|
-
throw error;
|
|
749
|
-
const owner = await readLockOwner(lockPath);
|
|
750
|
-
const diagnosis = await diagnose(owner, declaredIdentity);
|
|
751
|
-
if (!owner || diagnosis.liveness !== "gone") {
|
|
752
|
-
if (isRecord(error))
|
|
753
|
-
Object.assign(error, { journalLock: diagnosis });
|
|
754
|
-
throw error;
|
|
755
|
-
}
|
|
756
|
-
await unlink(lockPath).catch(() => {
|
|
757
|
-
return;
|
|
758
|
-
});
|
|
759
|
-
return {
|
|
760
|
-
handle: await createExclusive(lockPath, mode, declaredIdentity),
|
|
761
|
-
reclaimedStale: true
|
|
762
|
-
};
|
|
763
|
-
}
|
|
764
|
-
}
|
|
879
|
+
// src/observability/status.ts
|
|
880
|
+
import { z as z6 } from "zod";
|
|
881
|
+
var NonNegativeIntegerSchema2 = z6.number().int().nonnegative();
|
|
882
|
+
var ObservabilitySinkStatusSchema = z6.object({
|
|
883
|
+
capacity: NonNegativeIntegerSchema2,
|
|
884
|
+
received: NonNegativeIntegerSchema2,
|
|
885
|
+
accepted: NonNegativeIntegerSchema2,
|
|
886
|
+
filtered: NonNegativeIntegerSchema2,
|
|
887
|
+
completed: NonNegativeIntegerSchema2,
|
|
888
|
+
dropped: NonNegativeIntegerSchema2,
|
|
889
|
+
failed: NonNegativeIntegerSchema2,
|
|
890
|
+
preparationFailed: NonNegativeIntegerSchema2,
|
|
891
|
+
preparing: NonNegativeIntegerSchema2,
|
|
892
|
+
pending: NonNegativeIntegerSchema2,
|
|
893
|
+
closed: z6.boolean()
|
|
894
|
+
}).readonly();
|
|
895
|
+
var ObservabilityStatusObjectSchema = z6.object({
|
|
896
|
+
request: ObservabilitySinkStatusSchema.optional(),
|
|
897
|
+
tools: ObservabilitySinkStatusSchema.optional(),
|
|
898
|
+
total: ObservabilitySinkStatusSchema
|
|
899
|
+
});
|
|
900
|
+
var ObservabilityStatusSchema = ObservabilityStatusObjectSchema.readonly();
|
|
901
|
+
var ObservabilityDrainReportSchema = ObservabilityStatusObjectSchema.extend({
|
|
902
|
+
durationMs: z6.number().nonnegative()
|
|
903
|
+
}).readonly();
|
|
765
904
|
|
|
766
|
-
// src/
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
super(message, options);
|
|
771
|
-
this.phase = phase;
|
|
772
|
-
this.name = "DiagnosticJournalStorageError";
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
function isMissing(error) {
|
|
776
|
-
return isRecord(error) && error.code === "ENOENT";
|
|
777
|
-
}
|
|
778
|
-
async function removeIfPresent(path) {
|
|
779
|
-
try {
|
|
780
|
-
const info = await lstat(path);
|
|
781
|
-
if (info.isSymbolicLink() || !info.isFile()) {
|
|
782
|
-
throw new Error("Diagnostic journal generations must be regular files");
|
|
783
|
-
}
|
|
784
|
-
await unlink2(path);
|
|
785
|
-
} catch (error) {
|
|
786
|
-
if (!isMissing(error))
|
|
787
|
-
throw error;
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
async function moveIfPresent(from, to) {
|
|
791
|
-
try {
|
|
792
|
-
const info = await lstat(from);
|
|
793
|
-
if (info.isSymbolicLink() || !info.isFile()) {
|
|
794
|
-
throw new Error("Diagnostic journal generations must be regular files");
|
|
795
|
-
}
|
|
796
|
-
await rename(from, to);
|
|
797
|
-
return true;
|
|
798
|
-
} catch (error) {
|
|
799
|
-
if (isMissing(error))
|
|
800
|
-
return false;
|
|
801
|
-
throw error;
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
function generation(path, index) {
|
|
805
|
-
return `${path}.${index}`;
|
|
806
|
-
}
|
|
807
|
-
function generationIndex(name, prefix) {
|
|
808
|
-
if (!name.startsWith(prefix))
|
|
905
|
+
// src/internal/observability-sink.ts
|
|
906
|
+
var DEFAULT_MAX_PENDING = 1000;
|
|
907
|
+
function invokeIsolated(callback) {
|
|
908
|
+
if (!callback)
|
|
809
909
|
return;
|
|
810
|
-
|
|
811
|
-
if (!/^\d+$/.test(suffix))
|
|
812
|
-
return;
|
|
813
|
-
const value = Number(suffix);
|
|
814
|
-
return Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
910
|
+
Promise.resolve().then(callback).catch(() => {});
|
|
815
911
|
}
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
912
|
+
function createBoundedSinkManager(config) {
|
|
913
|
+
const maxPending = config.maxPending ?? DEFAULT_MAX_PENDING;
|
|
914
|
+
if (!Number.isSafeInteger(maxPending) || maxPending <= 0) {
|
|
915
|
+
throw new TypeError("Observability maxPending must be a positive safe integer");
|
|
819
916
|
}
|
|
820
|
-
|
|
821
|
-
const journalPath = resolve(parent, basename(config.path));
|
|
822
|
-
const lockPath = `${journalPath}.lock`;
|
|
823
|
-
const { handle: lock, reclaimedStale } = await acquireDiagnosticJournalLock(lockPath, config.mode, config.lock, config.machineIdentity);
|
|
824
|
-
let handle;
|
|
825
|
-
let currentFileBytes = 0;
|
|
826
|
-
let retainedFiles = 1;
|
|
827
|
-
let rotations = 0;
|
|
828
|
-
let partialTails = 0;
|
|
917
|
+
let sequence = 0;
|
|
829
918
|
let closed = false;
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
919
|
+
let received = 0;
|
|
920
|
+
let accepted = 0;
|
|
921
|
+
let filtered = 0;
|
|
922
|
+
let completed = 0;
|
|
923
|
+
let dropped = 0;
|
|
924
|
+
let failed = 0;
|
|
925
|
+
let preparationFailed = 0;
|
|
926
|
+
let closePromise;
|
|
927
|
+
const preparing = new Map;
|
|
928
|
+
const writes = new Map;
|
|
929
|
+
const reportError = (error, event) => {
|
|
930
|
+
invokeIsolated(config.onSinkError ? () => config.onSinkError?.({ error, ...event !== undefined && { event } }) : undefined);
|
|
839
931
|
};
|
|
840
|
-
const
|
|
932
|
+
const reportDrop = (reason, event) => {
|
|
933
|
+
invokeIsolated(config.onDrop ? () => config.onDrop?.({ reason, event, pending: writes.size }) : undefined);
|
|
934
|
+
};
|
|
935
|
+
const admit = (id, event) => {
|
|
841
936
|
try {
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
await removeIfPresent(journalPath);
|
|
846
|
-
} else {
|
|
847
|
-
await removeIfPresent(generation(journalPath, config.maxFiles - 1));
|
|
848
|
-
for (let index = config.maxFiles - 2;index >= 1; index -= 1) {
|
|
849
|
-
await moveIfPresent(generation(journalPath, index), generation(journalPath, index + 1));
|
|
850
|
-
}
|
|
851
|
-
await moveIfPresent(journalPath, generation(journalPath, 1));
|
|
937
|
+
if (config.filter && !config.filter(event)) {
|
|
938
|
+
filtered += 1;
|
|
939
|
+
return;
|
|
852
940
|
}
|
|
853
|
-
retainedFiles = Math.min(config.maxFiles, retainedFiles + 1);
|
|
854
|
-
rotations += 1;
|
|
855
|
-
await openCurrent();
|
|
856
941
|
} catch (error) {
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
};
|
|
860
|
-
try {
|
|
861
|
-
const names = await readdir(parent);
|
|
862
|
-
const prefix = `${basename(journalPath)}.`;
|
|
863
|
-
let existingGenerations = 0;
|
|
864
|
-
for (const name of names) {
|
|
865
|
-
const index = generationIndex(name, prefix);
|
|
866
|
-
if (index === undefined)
|
|
867
|
-
continue;
|
|
868
|
-
if (index >= config.maxFiles) {
|
|
869
|
-
await removeIfPresent(resolve(parent, name));
|
|
870
|
-
} else {
|
|
871
|
-
const info = await lstat(resolve(parent, name));
|
|
872
|
-
if (info.isSymbolicLink() || !info.isFile()) {
|
|
873
|
-
throw new Error("Diagnostic journal generations must be regular files");
|
|
874
|
-
}
|
|
875
|
-
existingGenerations += 1;
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
retainedFiles = Math.min(config.maxFiles, existingGenerations + 1);
|
|
879
|
-
await openCurrent();
|
|
880
|
-
if (currentFileBytes > 0) {
|
|
881
|
-
const tail = new Uint8Array(1);
|
|
882
|
-
const read = await handle?.read(tail, 0, 1, currentFileBytes - 1);
|
|
883
|
-
if (read?.bytesRead === 1 && tail[0] !== 10) {
|
|
884
|
-
partialTails += 1;
|
|
885
|
-
await rotate();
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
} catch (error) {
|
|
889
|
-
await handle?.close().catch(() => {
|
|
890
|
-
return;
|
|
891
|
-
});
|
|
892
|
-
await lock.close().catch(() => {
|
|
942
|
+
preparationFailed += 1;
|
|
943
|
+
reportError(error, event);
|
|
893
944
|
return;
|
|
894
|
-
}
|
|
895
|
-
|
|
945
|
+
}
|
|
946
|
+
if (writes.size >= maxPending) {
|
|
947
|
+
dropped += 1;
|
|
948
|
+
reportDrop("capacity", event);
|
|
896
949
|
return;
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
950
|
+
}
|
|
951
|
+
accepted += 1;
|
|
952
|
+
const write = Promise.resolve().then(() => config.write(event)).then(() => {
|
|
953
|
+
completed += 1;
|
|
954
|
+
}).catch((error) => {
|
|
955
|
+
failed += 1;
|
|
956
|
+
reportError(error, event);
|
|
957
|
+
}).finally(() => writes.delete(id));
|
|
958
|
+
writes.set(id, write);
|
|
959
|
+
};
|
|
960
|
+
const awaitGeneration = async (boundary) => {
|
|
961
|
+
const through = (values) => [...values].filter(([id]) => id <= boundary).map(([, value]) => value);
|
|
962
|
+
await Promise.allSettled(through(preparing));
|
|
963
|
+
await Promise.allSettled(through(writes));
|
|
964
|
+
};
|
|
906
965
|
return {
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
try {
|
|
918
|
-
await handle.writeFile(bytes);
|
|
919
|
-
currentFileBytes += bytes.byteLength;
|
|
920
|
-
return { ...snapshot(), rotated };
|
|
921
|
-
} catch (error) {
|
|
922
|
-
throw new DiagnosticJournalStorageError("write", "Diagnostic journal write failed", {
|
|
923
|
-
cause: error
|
|
966
|
+
submit(produce) {
|
|
967
|
+
received += 1;
|
|
968
|
+
const id = ++sequence;
|
|
969
|
+
if (closed) {
|
|
970
|
+
Promise.resolve().then(produce).then((event) => {
|
|
971
|
+
dropped += 1;
|
|
972
|
+
reportDrop("closed", event);
|
|
973
|
+
}).catch((error) => {
|
|
974
|
+
preparationFailed += 1;
|
|
975
|
+
reportError(error);
|
|
924
976
|
});
|
|
977
|
+
return;
|
|
925
978
|
}
|
|
979
|
+
const preparation = Promise.resolve().then(produce).then((event) => admit(id, event)).catch((error) => {
|
|
980
|
+
preparationFailed += 1;
|
|
981
|
+
reportError(error);
|
|
982
|
+
}).finally(() => preparing.delete(id));
|
|
983
|
+
preparing.set(id, preparation);
|
|
926
984
|
},
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
985
|
+
flush() {
|
|
986
|
+
return awaitGeneration(sequence);
|
|
987
|
+
},
|
|
988
|
+
getStatus,
|
|
989
|
+
close() {
|
|
990
|
+
if (closePromise)
|
|
991
|
+
return closePromise;
|
|
931
992
|
closed = true;
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
await handle?.close();
|
|
935
|
-
} catch (error) {
|
|
936
|
-
failure = error;
|
|
937
|
-
}
|
|
938
|
-
try {
|
|
939
|
-
await lock.close();
|
|
940
|
-
await unlink2(lockPath);
|
|
941
|
-
} catch (error) {
|
|
942
|
-
failure ??= error;
|
|
943
|
-
}
|
|
944
|
-
if (failure !== undefined) {
|
|
945
|
-
throw new DiagnosticJournalStorageError("close", "Diagnostic journal close failed", {
|
|
946
|
-
cause: failure
|
|
947
|
-
});
|
|
948
|
-
}
|
|
993
|
+
closePromise = awaitGeneration(sequence).then(getStatus);
|
|
994
|
+
return closePromise;
|
|
949
995
|
}
|
|
950
996
|
};
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
997
|
+
function getStatus() {
|
|
998
|
+
return ObservabilitySinkStatusSchema.parse({
|
|
999
|
+
capacity: maxPending,
|
|
1000
|
+
received,
|
|
1001
|
+
accepted,
|
|
1002
|
+
filtered,
|
|
1003
|
+
completed,
|
|
1004
|
+
dropped,
|
|
1005
|
+
failed,
|
|
1006
|
+
preparationFailed,
|
|
1007
|
+
preparing: preparing.size,
|
|
1008
|
+
pending: writes.size,
|
|
1009
|
+
closed
|
|
1010
|
+
});
|
|
959
1011
|
}
|
|
960
|
-
return options.timeoutMs;
|
|
961
|
-
}
|
|
962
|
-
function createDiagnosticJournalManager(config, storage) {
|
|
963
|
-
const limits = DiagnosticJournalLimitsSchema.parse(config.limits);
|
|
964
|
-
const encoder = new TextEncoder;
|
|
965
|
-
const channel = createBoundedChannel({
|
|
966
|
-
policy: "ordered",
|
|
967
|
-
maxItems: limits.maxPendingItems,
|
|
968
|
-
maxBytes: limits.maxPendingBytes,
|
|
969
|
-
sizeOf: (frame) => frame.bytes.byteLength
|
|
970
|
-
});
|
|
971
|
-
const refusals = {
|
|
972
|
-
closed: 0,
|
|
973
|
-
failed: 0,
|
|
974
|
-
invalid: 0,
|
|
975
|
-
oversized: 0,
|
|
976
|
-
"item-capacity": 0,
|
|
977
|
-
"byte-capacity": 0
|
|
978
|
-
};
|
|
979
|
-
const progressWaiters = new Set;
|
|
980
|
-
let state = "open";
|
|
981
|
-
let received = 0;
|
|
982
|
-
let accepted = 0;
|
|
983
|
-
let refused = 0;
|
|
984
|
-
let written = 0;
|
|
985
|
-
let failedRecords = 0;
|
|
986
|
-
let pendingItems = 0;
|
|
987
|
-
let pendingBytes = 0;
|
|
988
|
-
let inFlight = false;
|
|
989
|
-
let rotationFailures = 0;
|
|
990
|
-
let lastAcceptedSequence = 0;
|
|
991
|
-
let lastWrittenSequence = 0;
|
|
992
|
-
let lastSettledSequence = 0;
|
|
993
|
-
let lastFailure;
|
|
994
|
-
let physicalClosed = false;
|
|
995
|
-
const isFailed = () => state === "failed";
|
|
996
|
-
const notifyProgress = () => {
|
|
997
|
-
for (const waiter of progressWaiters)
|
|
998
|
-
waiter();
|
|
999
|
-
};
|
|
1000
|
-
const reportFailure = (failure) => {
|
|
1001
|
-
if (!config.onFailure)
|
|
1002
|
-
return;
|
|
1003
|
-
Promise.resolve().then(() => config.onFailure?.(failure)).catch(() => {
|
|
1004
|
-
return;
|
|
1005
|
-
});
|
|
1006
|
-
};
|
|
1007
|
-
const markFailure = (error, fallbackPhase, sequence) => {
|
|
1008
|
-
const phase = error instanceof DiagnosticJournalStorageError ? error.phase : fallbackPhase;
|
|
1009
|
-
if (phase === "rotation")
|
|
1010
|
-
rotationFailures += 1;
|
|
1011
|
-
lastFailure = { phase, ...sequence !== undefined && { sequence } };
|
|
1012
|
-
state = "failed";
|
|
1013
|
-
channel.close({ mode: "drain" });
|
|
1014
|
-
reportFailure({ phase, error, ...sequence !== undefined && { sequence } });
|
|
1015
|
-
};
|
|
1016
|
-
const worker = (async () => {
|
|
1017
|
-
for await (const frame of channel) {
|
|
1018
|
-
inFlight = true;
|
|
1019
|
-
if (isFailed()) {
|
|
1020
|
-
failedRecords += 1;
|
|
1021
|
-
} else {
|
|
1022
|
-
try {
|
|
1023
|
-
await storage.append(frame.bytes);
|
|
1024
|
-
written += 1;
|
|
1025
|
-
lastWrittenSequence = frame.sequence;
|
|
1026
|
-
} catch (error) {
|
|
1027
|
-
failedRecords += 1;
|
|
1028
|
-
markFailure(error, "write", frame.sequence);
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
pendingItems -= 1;
|
|
1032
|
-
pendingBytes -= frame.bytes.byteLength;
|
|
1033
|
-
lastSettledSequence = frame.sequence;
|
|
1034
|
-
inFlight = false;
|
|
1035
|
-
notifyProgress();
|
|
1036
|
-
}
|
|
1037
|
-
try {
|
|
1038
|
-
await storage.close();
|
|
1039
|
-
} catch (error) {
|
|
1040
|
-
markFailure(error, "close");
|
|
1041
|
-
}
|
|
1042
|
-
physicalClosed = true;
|
|
1043
|
-
if (!isFailed())
|
|
1044
|
-
state = "closed";
|
|
1045
|
-
notifyProgress();
|
|
1046
|
-
})();
|
|
1047
|
-
worker.catch((error) => {
|
|
1048
|
-
markFailure(error, "close");
|
|
1049
|
-
physicalClosed = true;
|
|
1050
|
-
notifyProgress();
|
|
1051
|
-
});
|
|
1052
|
-
const refuse = (reason) => {
|
|
1053
|
-
refused += 1;
|
|
1054
|
-
refusals[reason] += 1;
|
|
1055
|
-
return DiagnosticJournalSubmitResultSchema.parse({ outcome: "refused", reason });
|
|
1056
|
-
};
|
|
1057
|
-
const waitUntil = (predicate, options) => {
|
|
1058
|
-
const timeoutMs = waitBudget(options);
|
|
1059
|
-
if (predicate())
|
|
1060
|
-
return Promise.resolve("settled");
|
|
1061
|
-
if (options.signal?.aborted)
|
|
1062
|
-
return Promise.resolve("cancelled");
|
|
1063
|
-
return new Promise((resolve2) => {
|
|
1064
|
-
let settled = false;
|
|
1065
|
-
let timer;
|
|
1066
|
-
const finish = (outcome) => {
|
|
1067
|
-
if (settled)
|
|
1068
|
-
return;
|
|
1069
|
-
settled = true;
|
|
1070
|
-
progressWaiters.delete(onProgress);
|
|
1071
|
-
options.signal?.removeEventListener("abort", onAbort);
|
|
1072
|
-
if (timer)
|
|
1073
|
-
clearTimeout(timer);
|
|
1074
|
-
resolve2(outcome);
|
|
1075
|
-
};
|
|
1076
|
-
const onProgress = () => {
|
|
1077
|
-
if (predicate())
|
|
1078
|
-
finish("settled");
|
|
1079
|
-
};
|
|
1080
|
-
const onAbort = () => finish("cancelled");
|
|
1081
|
-
progressWaiters.add(onProgress);
|
|
1082
|
-
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1083
|
-
if (timeoutMs !== undefined)
|
|
1084
|
-
timer = setTimeout(() => finish("timed-out"), timeoutMs);
|
|
1085
|
-
onProgress();
|
|
1086
|
-
});
|
|
1087
|
-
};
|
|
1088
|
-
const getStatus = () => {
|
|
1089
|
-
const file = storage.snapshot();
|
|
1090
|
-
return DiagnosticJournalStatusSchema.parse({
|
|
1091
|
-
state,
|
|
1092
|
-
epoch: config.epoch,
|
|
1093
|
-
limits,
|
|
1094
|
-
lock: config.lock,
|
|
1095
|
-
received,
|
|
1096
|
-
accepted,
|
|
1097
|
-
refused,
|
|
1098
|
-
refusals,
|
|
1099
|
-
written,
|
|
1100
|
-
failedRecords,
|
|
1101
|
-
pendingItems,
|
|
1102
|
-
pendingBytes,
|
|
1103
|
-
inFlight,
|
|
1104
|
-
rotations: file.rotations,
|
|
1105
|
-
rotationFailures,
|
|
1106
|
-
partialTails: file.partialTails,
|
|
1107
|
-
currentFileBytes: file.currentFileBytes,
|
|
1108
|
-
retainedFiles: file.retainedFiles,
|
|
1109
|
-
...lastAcceptedSequence > 0 && { lastAcceptedSequence },
|
|
1110
|
-
...lastWrittenSequence > 0 && { lastWrittenSequence },
|
|
1111
|
-
...lastSettledSequence > 0 && { lastSettledSequence },
|
|
1112
|
-
...lastFailure && { lastFailure }
|
|
1113
|
-
});
|
|
1114
|
-
};
|
|
1115
|
-
return {
|
|
1116
|
-
submit(event) {
|
|
1117
|
-
received += 1;
|
|
1118
|
-
if (state === "failed")
|
|
1119
|
-
return refuse("failed");
|
|
1120
|
-
if (state !== "open")
|
|
1121
|
-
return refuse("closed");
|
|
1122
|
-
const parsed = config.eventSchema.safeParse(event);
|
|
1123
|
-
if (!parsed.success)
|
|
1124
|
-
return refuse("invalid");
|
|
1125
|
-
const json = z4.json().safeParse(parsed.data);
|
|
1126
|
-
if (!json.success)
|
|
1127
|
-
return refuse("invalid");
|
|
1128
|
-
const payload = encoder.encode(JSON.stringify(json.data));
|
|
1129
|
-
if (payload.byteLength > limits.maxEventBytes)
|
|
1130
|
-
return refuse("oversized");
|
|
1131
|
-
if (pendingItems >= limits.maxPendingItems)
|
|
1132
|
-
return refuse("item-capacity");
|
|
1133
|
-
const sequence = lastAcceptedSequence + 1;
|
|
1134
|
-
const frame = DiagnosticJournalFrameSchema.parse({
|
|
1135
|
-
schemaVersion: 1,
|
|
1136
|
-
epoch: config.epoch,
|
|
1137
|
-
sequence,
|
|
1138
|
-
event: json.data
|
|
1139
|
-
});
|
|
1140
|
-
const bytes = encoder.encode(`${JSON.stringify(frame)}
|
|
1141
|
-
`);
|
|
1142
|
-
if (bytes.byteLength > limits.maxFileBytes)
|
|
1143
|
-
return refuse("oversized");
|
|
1144
|
-
if (pendingBytes + bytes.byteLength > limits.maxPendingBytes) {
|
|
1145
|
-
return refuse("byte-capacity");
|
|
1146
|
-
}
|
|
1147
|
-
const offered = channel.offer({ sequence, bytes });
|
|
1148
|
-
if (offered.outcome === "refused") {
|
|
1149
|
-
return refuse(offered.reason === "not-open" ? "closed" : "item-capacity");
|
|
1150
|
-
}
|
|
1151
|
-
accepted += 1;
|
|
1152
|
-
pendingItems += 1;
|
|
1153
|
-
pendingBytes += bytes.byteLength;
|
|
1154
|
-
lastAcceptedSequence = sequence;
|
|
1155
|
-
return DiagnosticJournalSubmitResultSchema.parse({
|
|
1156
|
-
outcome: "accepted",
|
|
1157
|
-
epoch: config.epoch,
|
|
1158
|
-
sequence
|
|
1159
|
-
});
|
|
1160
|
-
},
|
|
1161
|
-
async flush(options = {}) {
|
|
1162
|
-
const throughSequence = lastAcceptedSequence;
|
|
1163
|
-
const outcome = await waitUntil(() => lastSettledSequence >= throughSequence, options);
|
|
1164
|
-
return DiagnosticJournalWaitResultSchema.parse({
|
|
1165
|
-
outcome,
|
|
1166
|
-
state,
|
|
1167
|
-
throughSequence,
|
|
1168
|
-
settledSequence: lastSettledSequence
|
|
1169
|
-
});
|
|
1170
|
-
},
|
|
1171
|
-
getStatus,
|
|
1172
|
-
async close(options = {}) {
|
|
1173
|
-
if (state === "open") {
|
|
1174
|
-
state = "draining";
|
|
1175
|
-
channel.close({ mode: "drain" });
|
|
1176
|
-
}
|
|
1177
|
-
const wait = await waitUntil(() => physicalClosed, options);
|
|
1178
|
-
return DiagnosticJournalCloseResultSchema.parse({
|
|
1179
|
-
outcome: wait === "settled" ? "closed" : wait,
|
|
1180
|
-
state,
|
|
1181
|
-
pendingItems
|
|
1182
|
-
});
|
|
1183
|
-
}
|
|
1184
|
-
};
|
|
1185
1012
|
}
|
|
1186
1013
|
|
|
1187
|
-
// src/application/diagnostic-journal.ts
|
|
1188
|
-
async function createDiagnosticJournal(config) {
|
|
1189
|
-
const limits = DiagnosticJournalLimitsSchema.parse(config.limits);
|
|
1190
|
-
const lock = DiagnosticJournalLockPolicySchema.parse(config.lock ?? "refuse");
|
|
1191
|
-
const storage = await createRotatingDiagnosticJournalStorage({
|
|
1192
|
-
path: config.path,
|
|
1193
|
-
maxFileBytes: limits.maxFileBytes,
|
|
1194
|
-
maxFiles: limits.maxFiles,
|
|
1195
|
-
mode: parseDiagnosticJournalMode(config.mode),
|
|
1196
|
-
lock,
|
|
1197
|
-
...config.machineIdentity !== undefined && { machineIdentity: config.machineIdentity }
|
|
1198
|
-
});
|
|
1199
|
-
return createDiagnosticJournalManager({
|
|
1200
|
-
eventSchema: config.eventSchema,
|
|
1201
|
-
epoch: randomUUID(),
|
|
1202
|
-
limits,
|
|
1203
|
-
lock: { policy: lock, reclaimedStale: storage.reclaimedStale },
|
|
1204
|
-
...config.onFailure && { onFailure: config.onFailure }
|
|
1205
|
-
}, storage);
|
|
1206
|
-
}
|
|
1207
1014
|
// src/application/events.ts
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
type: z5.literal("application-state"),
|
|
1015
|
+
var ApplicationLifecycleEventSchema = z7.object({
|
|
1016
|
+
type: z7.literal("application-state"),
|
|
1211
1017
|
applicationId: ApplicationIdSchema,
|
|
1212
|
-
epoch:
|
|
1213
|
-
revision:
|
|
1018
|
+
epoch: z7.string().uuid(),
|
|
1019
|
+
revision: z7.number().int().nonnegative(),
|
|
1214
1020
|
lifecycle: ApplicationLifecycleSchema,
|
|
1215
1021
|
health: ApplicationHealthSchema,
|
|
1216
|
-
ready:
|
|
1217
|
-
capturedAt:
|
|
1218
|
-
resources:
|
|
1022
|
+
ready: z7.boolean(),
|
|
1023
|
+
capturedAt: z7.string().datetime({ offset: true }),
|
|
1024
|
+
resources: z7.array(ManagedResourceSnapshotSchema).readonly()
|
|
1219
1025
|
}).strict().readonly();
|
|
1220
1026
|
function applicationLifecycleEvent(snapshot) {
|
|
1221
1027
|
return ApplicationLifecycleEventSchema.parse({
|
|
@@ -1246,13 +1052,13 @@ function createApplicationEventSink(config) {
|
|
|
1246
1052
|
};
|
|
1247
1053
|
}
|
|
1248
1054
|
// src/application/health.ts
|
|
1249
|
-
import { z as
|
|
1250
|
-
var ApplicationHealthHandlerOptionsSchema =
|
|
1251
|
-
kind:
|
|
1252
|
-
retryAfterSeconds:
|
|
1055
|
+
import { z as z8 } from "zod";
|
|
1056
|
+
var ApplicationHealthHandlerOptionsSchema = z8.object({
|
|
1057
|
+
kind: z8.enum(["liveness", "readiness"]),
|
|
1058
|
+
retryAfterSeconds: z8.number().int().nonnegative().default(5)
|
|
1253
1059
|
});
|
|
1254
|
-
var ApplicationOperationalHandlersOptionsSchema =
|
|
1255
|
-
retryAfterSeconds:
|
|
1060
|
+
var ApplicationOperationalHandlersOptionsSchema = z8.object({
|
|
1061
|
+
retryAfterSeconds: z8.number().int().nonnegative().default(5)
|
|
1256
1062
|
});
|
|
1257
1063
|
function createApplicationHealthHandler(application, options) {
|
|
1258
1064
|
const parsed = ApplicationHealthHandlerOptionsSchema.parse(options);
|
|
@@ -1279,6 +1085,901 @@ function createApplicationOperationalHandlers(application, options = {}) {
|
|
|
1279
1085
|
})
|
|
1280
1086
|
};
|
|
1281
1087
|
}
|
|
1088
|
+
// src/application/kernel.ts
|
|
1089
|
+
import { z as z10 } from "zod";
|
|
1090
|
+
|
|
1091
|
+
// src/server/shutdown.ts
|
|
1092
|
+
import { z as z9 } from "zod";
|
|
1093
|
+
|
|
1094
|
+
// src/server/http-stream-lifetime.ts
|
|
1095
|
+
var requests = new WeakMap;
|
|
1096
|
+
|
|
1097
|
+
// src/server/shutdown.ts
|
|
1098
|
+
var ShutdownStateSchema = z9.enum([
|
|
1099
|
+
"running",
|
|
1100
|
+
"draining-http",
|
|
1101
|
+
"closing-realtime",
|
|
1102
|
+
"stopping-runtime",
|
|
1103
|
+
"clean",
|
|
1104
|
+
"forced"
|
|
1105
|
+
]);
|
|
1106
|
+
var ShutdownOptionsSchema = z9.object({
|
|
1107
|
+
gracePeriodMs: z9.number().int().nonnegative().default(30000),
|
|
1108
|
+
realtimeCloseTimeoutMs: z9.number().int().nonnegative().default(1000),
|
|
1109
|
+
forceTimeoutMs: z9.number().int().nonnegative().default(5000),
|
|
1110
|
+
retryAfterSeconds: z9.number().int().nonnegative().default(5),
|
|
1111
|
+
signal: z9.custom((value) => typeof value === "object" && value !== null && ("aborted" in value) && ("addEventListener" in value), "Expected an AbortSignal").optional()
|
|
1112
|
+
});
|
|
1113
|
+
var ShutdownStatusSchema = z9.object({
|
|
1114
|
+
state: ShutdownStateSchema,
|
|
1115
|
+
acceptedRequests: z9.number().int().nonnegative(),
|
|
1116
|
+
completedRequests: z9.number().int().nonnegative(),
|
|
1117
|
+
pendingRequests: z9.number().int().nonnegative(),
|
|
1118
|
+
pendingWebSockets: z9.number().int().nonnegative()
|
|
1119
|
+
});
|
|
1120
|
+
var ShutdownResultSchema = z9.object({
|
|
1121
|
+
outcome: z9.enum(["clean", "forced"]),
|
|
1122
|
+
reason: z9.enum(["deadline", "signal"]).optional(),
|
|
1123
|
+
acceptedRequests: z9.number().int().nonnegative(),
|
|
1124
|
+
completedRequests: z9.number().int().nonnegative(),
|
|
1125
|
+
pendingRequests: z9.number().int().nonnegative(),
|
|
1126
|
+
pendingWebSockets: z9.number().int().nonnegative(),
|
|
1127
|
+
pendingRequestsAtForce: z9.number().int().nonnegative(),
|
|
1128
|
+
pendingWebSocketsAtForce: z9.number().int().nonnegative(),
|
|
1129
|
+
abortedRequests: z9.number().int().nonnegative(),
|
|
1130
|
+
forcedWebSockets: z9.number().int().nonnegative(),
|
|
1131
|
+
durationMs: z9.number().nonnegative()
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
// src/application/resource.ts
|
|
1135
|
+
function managedResourceDependencyId(dependency) {
|
|
1136
|
+
return typeof dependency === "string" ? dependency : dependency.id;
|
|
1137
|
+
}
|
|
1138
|
+
function defineManagedResource(resource) {
|
|
1139
|
+
return resource;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// src/application/graph.ts
|
|
1143
|
+
function resolveResourceGraph(resources) {
|
|
1144
|
+
const entries = resources.map((resource, declarationIndex) => ({
|
|
1145
|
+
resource,
|
|
1146
|
+
id: ApplicationIdSchema.parse(resource.id),
|
|
1147
|
+
dependsOn: [...resource.dependsOn ?? []].map((dependency) => ApplicationIdSchema.parse(managedResourceDependencyId(dependency))),
|
|
1148
|
+
required: resource.required ?? true,
|
|
1149
|
+
declarationIndex
|
|
1150
|
+
}));
|
|
1151
|
+
const byId = new Map;
|
|
1152
|
+
for (const entry of entries) {
|
|
1153
|
+
if (byId.has(entry.id)) {
|
|
1154
|
+
throw new Error(`[stitchkit] createApplication: duplicate resource id "${entry.id}"`);
|
|
1155
|
+
}
|
|
1156
|
+
byId.set(entry.id, entry);
|
|
1157
|
+
}
|
|
1158
|
+
for (const entry of entries) {
|
|
1159
|
+
for (const dependencyId of entry.dependsOn) {
|
|
1160
|
+
const dependency = byId.get(dependencyId);
|
|
1161
|
+
if (!dependency) {
|
|
1162
|
+
throw new Error(`[stitchkit] createApplication: resource "${entry.id}" depends on missing resource "${dependencyId}"`);
|
|
1163
|
+
}
|
|
1164
|
+
if (entry.required && !dependency.required) {
|
|
1165
|
+
throw new Error(`[stitchkit] createApplication: required resource "${entry.id}" cannot depend on optional resource "${dependencyId}"`);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
const pending = new Map(entries.map((entry) => [entry.id, entry]));
|
|
1170
|
+
const resolved = new Set;
|
|
1171
|
+
const ordered = [];
|
|
1172
|
+
while (pending.size > 0) {
|
|
1173
|
+
const ready = [...pending.values()].filter((entry) => entry.dependsOn.every((id) => resolved.has(id))).sort((left, right) => left.declarationIndex - right.declarationIndex);
|
|
1174
|
+
if (ready.length === 0) {
|
|
1175
|
+
throw new Error(`[stitchkit] createApplication: resource dependency cycle: ${[...pending.keys()].join(", ")}`);
|
|
1176
|
+
}
|
|
1177
|
+
for (const entry of ready) {
|
|
1178
|
+
pending.delete(entry.id);
|
|
1179
|
+
resolved.add(entry.id);
|
|
1180
|
+
ordered.push(entry);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
return ordered;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// src/application/kernel.ts
|
|
1187
|
+
class ResourceCompletionBeforeReadyError extends Error {
|
|
1188
|
+
constructor(resourceId, cause) {
|
|
1189
|
+
super(`[stitchkit] resource "${resourceId}" completed before reaching readiness`, {
|
|
1190
|
+
...cause !== undefined && { cause }
|
|
1191
|
+
});
|
|
1192
|
+
this.name = "ResourceCompletionBeforeReadyError";
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
class ApplicationStartupInterruptedError extends Error {
|
|
1197
|
+
constructor() {
|
|
1198
|
+
super("[stitchkit] application startup interrupted by shutdown");
|
|
1199
|
+
this.name = "ApplicationStartupInterruptedError";
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
var ApplicationShutdownOptionsSchema = ShutdownOptionsSchema.pick({
|
|
1203
|
+
gracePeriodMs: true,
|
|
1204
|
+
forceTimeoutMs: true,
|
|
1205
|
+
signal: true
|
|
1206
|
+
});
|
|
1207
|
+
var ApplicationShutdownBudgetSchema = ShutdownOptionsSchema.pick({
|
|
1208
|
+
gracePeriodMs: true,
|
|
1209
|
+
forceTimeoutMs: true
|
|
1210
|
+
});
|
|
1211
|
+
var ApplicationRestartInputSchema = z10.object({
|
|
1212
|
+
resourceId: z10.string().min(1),
|
|
1213
|
+
gracePeriodMs: z10.number().int().nonnegative().optional(),
|
|
1214
|
+
forceTimeoutMs: z10.number().int().nonnegative().optional()
|
|
1215
|
+
}).strict().readonly();
|
|
1216
|
+
var ApplicationRestartOutcomeSchema = z10.enum(["restarted", "failed", "refused"]);
|
|
1217
|
+
var ApplicationRestartResultSchema = z10.object({
|
|
1218
|
+
resourceId: z10.string(),
|
|
1219
|
+
affected: z10.array(z10.string()).readonly(),
|
|
1220
|
+
outcome: ApplicationRestartOutcomeSchema,
|
|
1221
|
+
reason: z10.string().optional(),
|
|
1222
|
+
durationMs: z10.number().int().nonnegative()
|
|
1223
|
+
}).strict().readonly();
|
|
1224
|
+
|
|
1225
|
+
class ApplicationAdmissionError extends AppError {
|
|
1226
|
+
constructor() {
|
|
1227
|
+
super("APPLICATION_NOT_ACCEPTING", "Application is not accepting new operations", 503);
|
|
1228
|
+
this.name = "ApplicationAdmissionError";
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
function isStartResult(value) {
|
|
1232
|
+
return typeof value === "object" && value !== null;
|
|
1233
|
+
}
|
|
1234
|
+
function waitForAbort(signal) {
|
|
1235
|
+
if (signal.aborted)
|
|
1236
|
+
return Promise.resolve();
|
|
1237
|
+
return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
1238
|
+
}
|
|
1239
|
+
async function untilDeadline(work, signal) {
|
|
1240
|
+
const result = await Promise.race([
|
|
1241
|
+
work.then((value) => ({ settled: true, value }), (error) => ({ settled: true, error })),
|
|
1242
|
+
waitForAbort(signal).then(() => ({ settled: false }))
|
|
1243
|
+
]);
|
|
1244
|
+
return result;
|
|
1245
|
+
}
|
|
1246
|
+
function createApplication(config) {
|
|
1247
|
+
const id = ApplicationIdSchema.parse(config.id);
|
|
1248
|
+
const ordered = resolveResourceGraph(config.resources ?? []);
|
|
1249
|
+
const shutdownBudget = ApplicationShutdownBudgetSchema.parse(config.shutdown ?? {});
|
|
1250
|
+
const reverse = [...ordered].reverse();
|
|
1251
|
+
const reportFailure = (resourceId, phase, error) => {
|
|
1252
|
+
if (!config.onResourceFailure)
|
|
1253
|
+
return;
|
|
1254
|
+
try {
|
|
1255
|
+
Promise.resolve(config.onResourceFailure({ resourceId, phase, error })).catch(() => {
|
|
1256
|
+
return;
|
|
1257
|
+
});
|
|
1258
|
+
} catch {}
|
|
1259
|
+
};
|
|
1260
|
+
const published = new Map;
|
|
1261
|
+
const records = new Map;
|
|
1262
|
+
for (const entry of ordered) {
|
|
1263
|
+
records.set(entry.id, {
|
|
1264
|
+
entry,
|
|
1265
|
+
state: "registered",
|
|
1266
|
+
health: "unknown",
|
|
1267
|
+
healthReported: false,
|
|
1268
|
+
everHealthy: false,
|
|
1269
|
+
attempted: false,
|
|
1270
|
+
activated: false,
|
|
1271
|
+
closeInvoked: false,
|
|
1272
|
+
closed: false,
|
|
1273
|
+
failures: []
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
const epoch = crypto.randomUUID();
|
|
1277
|
+
const listeners = new Set;
|
|
1278
|
+
const lifetimeAbort = new AbortController;
|
|
1279
|
+
const startupAbort = new AbortController;
|
|
1280
|
+
let lifecycle = "created";
|
|
1281
|
+
let revision = 0;
|
|
1282
|
+
let changedAt = new Date().toISOString();
|
|
1283
|
+
let accepting = false;
|
|
1284
|
+
let accepted = 0;
|
|
1285
|
+
let completed = 0;
|
|
1286
|
+
let pending = 0;
|
|
1287
|
+
let startPromise;
|
|
1288
|
+
let shutdownPromise;
|
|
1289
|
+
let shutdownRequested = false;
|
|
1290
|
+
let activationComplete = false;
|
|
1291
|
+
const pendingWaiters = new Set;
|
|
1292
|
+
const aggregateHealth = () => {
|
|
1293
|
+
if (lifecycle === "created" || lifecycle === "starting")
|
|
1294
|
+
return "unknown";
|
|
1295
|
+
let optionalUnhealthy = false;
|
|
1296
|
+
for (const record of records.values()) {
|
|
1297
|
+
if (record.entry.required && (record.state !== "ready" || record.health !== "healthy")) {
|
|
1298
|
+
return "unhealthy";
|
|
1299
|
+
}
|
|
1300
|
+
if (!record.entry.required && (record.state !== "ready" || record.health !== "healthy")) {
|
|
1301
|
+
optionalUnhealthy = true;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
return optionalUnhealthy ? "degraded" : "healthy";
|
|
1305
|
+
};
|
|
1306
|
+
const isReady = () => lifecycle === "ready" && [...records.values()].every((record) => !record.entry.required || record.state === "ready" && record.health === "healthy");
|
|
1307
|
+
const snapshot = () => ApplicationSnapshotSchema.parse({
|
|
1308
|
+
id,
|
|
1309
|
+
epoch,
|
|
1310
|
+
revision,
|
|
1311
|
+
lifecycle,
|
|
1312
|
+
health: aggregateHealth(),
|
|
1313
|
+
ready: isReady(),
|
|
1314
|
+
capturedAt: new Date().toISOString(),
|
|
1315
|
+
changedAt,
|
|
1316
|
+
admission: { accepting, accepted, completed, pending },
|
|
1317
|
+
resources: ordered.map((entry) => {
|
|
1318
|
+
const record = records.get(entry.id);
|
|
1319
|
+
if (!record)
|
|
1320
|
+
throw new Error("Managed resource record disappeared");
|
|
1321
|
+
return {
|
|
1322
|
+
id: entry.id,
|
|
1323
|
+
required: entry.required,
|
|
1324
|
+
dependsOn: entry.dependsOn,
|
|
1325
|
+
state: record.state,
|
|
1326
|
+
health: record.health,
|
|
1327
|
+
ready: record.state === "ready" && record.health === "healthy"
|
|
1328
|
+
};
|
|
1329
|
+
})
|
|
1330
|
+
});
|
|
1331
|
+
const publish = () => {
|
|
1332
|
+
revision += 1;
|
|
1333
|
+
changedAt = new Date().toISOString();
|
|
1334
|
+
const value = snapshot();
|
|
1335
|
+
for (const listener of listeners) {
|
|
1336
|
+
try {
|
|
1337
|
+
listener(value);
|
|
1338
|
+
} catch {}
|
|
1339
|
+
}
|
|
1340
|
+
if (config.onSnapshot) {
|
|
1341
|
+
Promise.resolve().then(() => config.onSnapshot?.(value)).catch(() => {});
|
|
1342
|
+
}
|
|
1343
|
+
};
|
|
1344
|
+
const contextFor = (record, options = {}) => ({
|
|
1345
|
+
applicationId: id,
|
|
1346
|
+
signal: options.signal ?? lifetimeAbort.signal,
|
|
1347
|
+
...options.deadlineAt !== undefined && { deadlineAt: options.deadlineAt },
|
|
1348
|
+
...options.forceDeadlineAt !== undefined && {
|
|
1349
|
+
forceDeadlineAt: options.forceDeadlineAt
|
|
1350
|
+
},
|
|
1351
|
+
now: () => performance.now(),
|
|
1352
|
+
use(resource) {
|
|
1353
|
+
const dependencyId = resource?.id;
|
|
1354
|
+
if (typeof dependencyId !== "string" || dependencyId.length === 0) {
|
|
1355
|
+
throw new Error(`[stitchkit] resource "${record.entry.id}": use() takes a managed resource, and this one has no id`);
|
|
1356
|
+
}
|
|
1357
|
+
if (!record.entry.dependsOn.includes(dependencyId)) {
|
|
1358
|
+
throw new Error(`[stitchkit] resource "${record.entry.id}" used "${dependencyId}" without declaring it in dependsOn`);
|
|
1359
|
+
}
|
|
1360
|
+
if (!published.has(dependencyId)) {
|
|
1361
|
+
throw new Error(`[stitchkit] resource "${record.entry.id}" used "${dependencyId}", which published no value from start()`);
|
|
1362
|
+
}
|
|
1363
|
+
return published.get(dependencyId);
|
|
1364
|
+
},
|
|
1365
|
+
reportHealth(health) {
|
|
1366
|
+
if (record.state === "stopped")
|
|
1367
|
+
return;
|
|
1368
|
+
record.health = health;
|
|
1369
|
+
record.healthReported = true;
|
|
1370
|
+
if (health === "healthy")
|
|
1371
|
+
record.everHealthy = true;
|
|
1372
|
+
accepting = activationComplete && !shutdownRequested && isReady();
|
|
1373
|
+
publish();
|
|
1374
|
+
}
|
|
1375
|
+
});
|
|
1376
|
+
const markLateCompletion = (record, failure) => {
|
|
1377
|
+
if (shutdownRequested || record.state === "stopping" || record.state === "stopped")
|
|
1378
|
+
return;
|
|
1379
|
+
if (failure) {
|
|
1380
|
+
record.failures.push("completion");
|
|
1381
|
+
reportFailure(record.entry.id, "completion", failure.error);
|
|
1382
|
+
}
|
|
1383
|
+
record.state = "failed";
|
|
1384
|
+
record.health = "unhealthy";
|
|
1385
|
+
accepting = activationComplete && isReady();
|
|
1386
|
+
publish();
|
|
1387
|
+
};
|
|
1388
|
+
const closeAttempted = async () => {
|
|
1389
|
+
const errors = [];
|
|
1390
|
+
const rollbackStartedAt = performance.now();
|
|
1391
|
+
const rollbackDeadlineAt = rollbackStartedAt + shutdownBudget.gracePeriodMs;
|
|
1392
|
+
const rollbackForceDeadlineAt = rollbackDeadlineAt + shutdownBudget.forceTimeoutMs;
|
|
1393
|
+
const rollbackAbort = new AbortController;
|
|
1394
|
+
const rollbackTimer = setTimeout(() => rollbackAbort.abort(), Math.max(0, rollbackForceDeadlineAt - performance.now()));
|
|
1395
|
+
try {
|
|
1396
|
+
await closeEachAttempted(rollbackAbort.signal, errors, {
|
|
1397
|
+
deadlineAt: rollbackDeadlineAt,
|
|
1398
|
+
forceDeadlineAt: rollbackForceDeadlineAt
|
|
1399
|
+
});
|
|
1400
|
+
} finally {
|
|
1401
|
+
clearTimeout(rollbackTimer);
|
|
1402
|
+
}
|
|
1403
|
+
return errors;
|
|
1404
|
+
};
|
|
1405
|
+
const closeEachAttempted = async (bound, errors, deadlines) => {
|
|
1406
|
+
for (const entry of reverse) {
|
|
1407
|
+
const record = records.get(entry.id);
|
|
1408
|
+
if (!record?.attempted || record.closed)
|
|
1409
|
+
continue;
|
|
1410
|
+
record.state = "stopping";
|
|
1411
|
+
try {
|
|
1412
|
+
record.closeInvoked = true;
|
|
1413
|
+
const settled = await untilDeadline(Promise.resolve(entry.resource.close?.(contextFor(record, { signal: startupAbort.signal, ...deadlines }))), bound);
|
|
1414
|
+
if (!settled.settled) {
|
|
1415
|
+
record.failures.push("close");
|
|
1416
|
+
record.state = "failed";
|
|
1417
|
+
const timedOut = new Error(`[stitchkit] resource "${entry.id}" did not finish closing during rollback`);
|
|
1418
|
+
reportFailure(entry.id, "close", timedOut);
|
|
1419
|
+
errors.push(timedOut);
|
|
1420
|
+
publish();
|
|
1421
|
+
break;
|
|
1422
|
+
}
|
|
1423
|
+
if (settled.error !== undefined)
|
|
1424
|
+
throw settled.error;
|
|
1425
|
+
record.closed = true;
|
|
1426
|
+
record.state = "stopped";
|
|
1427
|
+
} catch (error) {
|
|
1428
|
+
record.failures.push("close");
|
|
1429
|
+
record.state = "failed";
|
|
1430
|
+
reportFailure(entry.id, "close", error);
|
|
1431
|
+
errors.push(error);
|
|
1432
|
+
}
|
|
1433
|
+
publish();
|
|
1434
|
+
}
|
|
1435
|
+
};
|
|
1436
|
+
const startEach = async (entries, signal) => {
|
|
1437
|
+
for (const entry of entries) {
|
|
1438
|
+
if (shutdownRequested || signal.aborted) {
|
|
1439
|
+
throw new ApplicationStartupInterruptedError;
|
|
1440
|
+
}
|
|
1441
|
+
const record = records.get(entry.id);
|
|
1442
|
+
if (!record)
|
|
1443
|
+
throw new Error("Managed resource record disappeared");
|
|
1444
|
+
const dependencyFailed = entry.dependsOn.some((dependencyId) => records.get(dependencyId)?.state !== "ready");
|
|
1445
|
+
if (dependencyFailed) {
|
|
1446
|
+
record.state = "failed";
|
|
1447
|
+
record.health = "unhealthy";
|
|
1448
|
+
record.failures.push("start");
|
|
1449
|
+
publish();
|
|
1450
|
+
if (entry.required) {
|
|
1451
|
+
throw new Error(`[stitchkit] required resource "${entry.id}" has an unavailable dependency`);
|
|
1452
|
+
}
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
record.attempted = true;
|
|
1456
|
+
record.state = "starting";
|
|
1457
|
+
publish();
|
|
1458
|
+
try {
|
|
1459
|
+
const started = await entry.resource.start(contextFor(record, { signal }));
|
|
1460
|
+
if (shutdownRequested || signal.aborted) {
|
|
1461
|
+
throw new ApplicationStartupInterruptedError;
|
|
1462
|
+
}
|
|
1463
|
+
if (isStartResult(started)) {
|
|
1464
|
+
record.runtime = started;
|
|
1465
|
+
if (started.value !== undefined)
|
|
1466
|
+
published.set(entry.id, started.value);
|
|
1467
|
+
let resourceReady = started.ready === undefined;
|
|
1468
|
+
let completionSettled = false;
|
|
1469
|
+
let completionFailure;
|
|
1470
|
+
const completion = started.completion?.then(() => {
|
|
1471
|
+
completionSettled = true;
|
|
1472
|
+
if (resourceReady)
|
|
1473
|
+
markLateCompletion(record);
|
|
1474
|
+
}, (error) => {
|
|
1475
|
+
completionSettled = true;
|
|
1476
|
+
completionFailure = error;
|
|
1477
|
+
if (resourceReady)
|
|
1478
|
+
markLateCompletion(record, { error });
|
|
1479
|
+
});
|
|
1480
|
+
if (started.ready && completion) {
|
|
1481
|
+
const readiness = started.ready.then(() => "ready");
|
|
1482
|
+
const completionBeforeReady = completion.then(() => "completion");
|
|
1483
|
+
const first = await Promise.race([readiness, completionBeforeReady]);
|
|
1484
|
+
if (first === "completion") {
|
|
1485
|
+
throw new ResourceCompletionBeforeReadyError(entry.id, completionFailure);
|
|
1486
|
+
}
|
|
1487
|
+
resourceReady = true;
|
|
1488
|
+
if (completionSettled) {
|
|
1489
|
+
throw new ResourceCompletionBeforeReadyError(entry.id, completionFailure);
|
|
1490
|
+
}
|
|
1491
|
+
} else if (started.ready) {
|
|
1492
|
+
await started.ready;
|
|
1493
|
+
resourceReady = true;
|
|
1494
|
+
} else if (completion) {}
|
|
1495
|
+
}
|
|
1496
|
+
if (shutdownRequested || signal.aborted) {
|
|
1497
|
+
throw new ApplicationStartupInterruptedError;
|
|
1498
|
+
}
|
|
1499
|
+
record.state = "ready";
|
|
1500
|
+
if (!record.healthReported) {
|
|
1501
|
+
record.health = "healthy";
|
|
1502
|
+
record.everHealthy = true;
|
|
1503
|
+
}
|
|
1504
|
+
publish();
|
|
1505
|
+
} catch (error) {
|
|
1506
|
+
const interrupted = shutdownRequested || signal.aborted;
|
|
1507
|
+
if (!(error instanceof ApplicationStartupInterruptedError)) {
|
|
1508
|
+
record.failures.push(error instanceof ResourceCompletionBeforeReadyError ? "completion" : record.runtime?.ready ? "ready" : "start");
|
|
1509
|
+
record.state = "failed";
|
|
1510
|
+
record.health = "unhealthy";
|
|
1511
|
+
reportFailure(entry.id, record.failures[record.failures.length - 1] ?? "start", error);
|
|
1512
|
+
publish();
|
|
1513
|
+
}
|
|
1514
|
+
if (interrupted || entry.required)
|
|
1515
|
+
throw error;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
};
|
|
1519
|
+
const activateEach = async (entries, signal) => {
|
|
1520
|
+
for (const entry of entries) {
|
|
1521
|
+
if (shutdownRequested || signal.aborted) {
|
|
1522
|
+
throw new ApplicationStartupInterruptedError;
|
|
1523
|
+
}
|
|
1524
|
+
const record = records.get(entry.id);
|
|
1525
|
+
if (record?.state !== "ready")
|
|
1526
|
+
continue;
|
|
1527
|
+
const dependencyUnavailable = entry.dependsOn.some((dependencyId) => {
|
|
1528
|
+
const dependency = records.get(dependencyId);
|
|
1529
|
+
return dependency?.state !== "ready" || !dependency.activated;
|
|
1530
|
+
});
|
|
1531
|
+
if (dependencyUnavailable) {
|
|
1532
|
+
record.failures.push("start");
|
|
1533
|
+
record.state = "failed";
|
|
1534
|
+
record.health = "unhealthy";
|
|
1535
|
+
publish();
|
|
1536
|
+
if (entry.required) {
|
|
1537
|
+
throw new Error(`[stitchkit] required resource "${entry.id}" has an unavailable activation dependency`);
|
|
1538
|
+
}
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
try {
|
|
1542
|
+
await entry.resource.activate?.(contextFor(record));
|
|
1543
|
+
if (shutdownRequested || signal.aborted) {
|
|
1544
|
+
throw new ApplicationStartupInterruptedError;
|
|
1545
|
+
}
|
|
1546
|
+
record.activated = true;
|
|
1547
|
+
if (entry.required && (record.state !== "ready" || record.health !== "healthy")) {
|
|
1548
|
+
const observed = `${record.state}/${record.health}`;
|
|
1549
|
+
throw new Error(record.everHealthy ? `[stitchkit] required resource "${entry.id}" lost readiness during activation (${observed})` : `[stitchkit] required resource "${entry.id}" is not healthy (${observed}). A required resource must be healthy for the application to be ready; a resource that is expected to start degraded belongs behind \`required: false\`.`);
|
|
1550
|
+
}
|
|
1551
|
+
} catch (error) {
|
|
1552
|
+
const interrupted = shutdownRequested || signal.aborted;
|
|
1553
|
+
if (!(error instanceof ApplicationStartupInterruptedError)) {
|
|
1554
|
+
record.failures.push("start");
|
|
1555
|
+
record.state = "failed";
|
|
1556
|
+
record.health = "unhealthy";
|
|
1557
|
+
reportFailure(entry.id, "start", error);
|
|
1558
|
+
publish();
|
|
1559
|
+
}
|
|
1560
|
+
if (interrupted || entry.required)
|
|
1561
|
+
throw error;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
};
|
|
1565
|
+
const subtreeOf = (resourceId) => {
|
|
1566
|
+
const affected = new Set([resourceId]);
|
|
1567
|
+
for (const entry of ordered) {
|
|
1568
|
+
if (entry.dependsOn.some((dependencyId) => affected.has(dependencyId))) {
|
|
1569
|
+
affected.add(entry.id);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
return ordered.filter((entry) => affected.has(entry.id));
|
|
1573
|
+
};
|
|
1574
|
+
const closeOne = async (entry, record, signal, deadlines) => {
|
|
1575
|
+
const context = () => contextFor(record, { ...deadlines, signal });
|
|
1576
|
+
if (record.attempted && !record.closed) {
|
|
1577
|
+
const phase = async (work, what) => {
|
|
1578
|
+
const settled = await untilDeadline(Promise.resolve(work), signal);
|
|
1579
|
+
if (!settled.settled) {
|
|
1580
|
+
throw new Error(`[stitchkit] resource "${entry.id}" did not ${what} within the restart budget`);
|
|
1581
|
+
}
|
|
1582
|
+
if (settled.error !== undefined)
|
|
1583
|
+
throw settled.error;
|
|
1584
|
+
};
|
|
1585
|
+
await phase(entry.resource.stopAdmission?.(context()), "stop admitting");
|
|
1586
|
+
await phase(entry.resource.drain?.(context()), "drain");
|
|
1587
|
+
record.closeInvoked = true;
|
|
1588
|
+
await phase(entry.resource.close?.(context()), "close");
|
|
1589
|
+
}
|
|
1590
|
+
record.closed = false;
|
|
1591
|
+
record.closeInvoked = false;
|
|
1592
|
+
record.attempted = false;
|
|
1593
|
+
record.activated = false;
|
|
1594
|
+
record.state = "registered";
|
|
1595
|
+
record.health = "unknown";
|
|
1596
|
+
record.healthReported = false;
|
|
1597
|
+
record.runtime = undefined;
|
|
1598
|
+
published.delete(entry.id);
|
|
1599
|
+
};
|
|
1600
|
+
let restarting = Promise.resolve();
|
|
1601
|
+
const runRestart = async (input) => {
|
|
1602
|
+
const startedAt = Date.now();
|
|
1603
|
+
const parsed = ApplicationRestartInputSchema.parse(input);
|
|
1604
|
+
const affected = subtreeOf(parsed.resourceId);
|
|
1605
|
+
const affectedIds = affected.map((entry) => entry.id);
|
|
1606
|
+
const refuse = (reason) => ({
|
|
1607
|
+
resourceId: parsed.resourceId,
|
|
1608
|
+
affected: affectedIds,
|
|
1609
|
+
outcome: "refused",
|
|
1610
|
+
reason,
|
|
1611
|
+
durationMs: Date.now() - startedAt
|
|
1612
|
+
});
|
|
1613
|
+
if (!records.has(parsed.resourceId)) {
|
|
1614
|
+
return refuse(`no resource is registered as "${parsed.resourceId}"`);
|
|
1615
|
+
}
|
|
1616
|
+
if (shutdownRequested) {
|
|
1617
|
+
return refuse("the application is shutting down");
|
|
1618
|
+
}
|
|
1619
|
+
if (lifecycle !== "ready") {
|
|
1620
|
+
return refuse(`the application is ${lifecycle}, not ready`);
|
|
1621
|
+
}
|
|
1622
|
+
const budget = {
|
|
1623
|
+
gracePeriodMs: parsed.gracePeriodMs ?? shutdownBudget.gracePeriodMs,
|
|
1624
|
+
forceTimeoutMs: parsed.forceTimeoutMs ?? shutdownBudget.forceTimeoutMs
|
|
1625
|
+
};
|
|
1626
|
+
const closeDeadlineAt = performance.now() + budget.gracePeriodMs;
|
|
1627
|
+
const deadlines = {
|
|
1628
|
+
deadlineAt: closeDeadlineAt,
|
|
1629
|
+
forceDeadlineAt: closeDeadlineAt + budget.forceTimeoutMs
|
|
1630
|
+
};
|
|
1631
|
+
const restartAbort = new AbortController;
|
|
1632
|
+
const closeTimer = setTimeout(() => restartAbort.abort(), Math.max(0, deadlines.forceDeadlineAt - performance.now()));
|
|
1633
|
+
const stillFailed = () => affectedIds.filter((id2) => records.get(id2)?.state === "failed");
|
|
1634
|
+
try {
|
|
1635
|
+
for (const entry of [...affected].reverse()) {
|
|
1636
|
+
const record = records.get(entry.id);
|
|
1637
|
+
if (record)
|
|
1638
|
+
await closeOne(entry, record, restartAbort.signal, deadlines);
|
|
1639
|
+
}
|
|
1640
|
+
publish();
|
|
1641
|
+
await startEach(affected, restartAbort.signal);
|
|
1642
|
+
await activateEach(affected, restartAbort.signal);
|
|
1643
|
+
publish();
|
|
1644
|
+
const failed = stillFailed();
|
|
1645
|
+
if (failed.length > 0) {
|
|
1646
|
+
return {
|
|
1647
|
+
resourceId: parsed.resourceId,
|
|
1648
|
+
affected: affectedIds,
|
|
1649
|
+
outcome: "failed",
|
|
1650
|
+
reason: `did not come back: ${failed.join(", ")}`,
|
|
1651
|
+
durationMs: Date.now() - startedAt
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
return {
|
|
1655
|
+
resourceId: parsed.resourceId,
|
|
1656
|
+
affected: affectedIds,
|
|
1657
|
+
outcome: "restarted",
|
|
1658
|
+
durationMs: Date.now() - startedAt
|
|
1659
|
+
};
|
|
1660
|
+
} catch (error) {
|
|
1661
|
+
restartAbort.abort();
|
|
1662
|
+
publish();
|
|
1663
|
+
return {
|
|
1664
|
+
resourceId: parsed.resourceId,
|
|
1665
|
+
affected: affectedIds,
|
|
1666
|
+
outcome: "failed",
|
|
1667
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
1668
|
+
durationMs: Date.now() - startedAt
|
|
1669
|
+
};
|
|
1670
|
+
} finally {
|
|
1671
|
+
clearTimeout(closeTimer);
|
|
1672
|
+
}
|
|
1673
|
+
};
|
|
1674
|
+
const runStart = async () => {
|
|
1675
|
+
lifecycle = "starting";
|
|
1676
|
+
publish();
|
|
1677
|
+
let startFailure;
|
|
1678
|
+
try {
|
|
1679
|
+
await startEach(ordered, startupAbort.signal);
|
|
1680
|
+
lifecycle = "ready";
|
|
1681
|
+
publish();
|
|
1682
|
+
await activateEach(ordered, startupAbort.signal);
|
|
1683
|
+
if (shutdownRequested) {
|
|
1684
|
+
throw new ApplicationStartupInterruptedError;
|
|
1685
|
+
}
|
|
1686
|
+
if (!isReady()) {
|
|
1687
|
+
const blocking = [...records.values()].filter((record) => record.entry.required && (record.state !== "ready" || record.health !== "healthy")).map((record) => `${record.entry.id} (${record.state}/${record.health})`);
|
|
1688
|
+
if (blocking.length === 0)
|
|
1689
|
+
blocking.push("(none identified)");
|
|
1690
|
+
const chosen = [...records.values()].some((record) => record.entry.required && record.health !== "healthy" && !record.everHealthy);
|
|
1691
|
+
throw new Error(`[stitchkit] the application is not ready after startup — required ${blocking.length === 1 ? "resource" : "resources"} ${blocking.join(", ")}. A required resource must be healthy for the application to be ready; ${chosen ? "a resource that is expected to start degraded belongs behind `required: false`." : "this one was healthy and stopped being so — read `onResourceFailure` for the cause."}`);
|
|
1692
|
+
}
|
|
1693
|
+
activationComplete = true;
|
|
1694
|
+
accepting = isReady();
|
|
1695
|
+
publish();
|
|
1696
|
+
return snapshot();
|
|
1697
|
+
} catch (error) {
|
|
1698
|
+
startFailure = error;
|
|
1699
|
+
}
|
|
1700
|
+
if (!shutdownRequested) {
|
|
1701
|
+
const rollbackErrors = await closeAttempted();
|
|
1702
|
+
lifecycle = "failed";
|
|
1703
|
+
accepting = false;
|
|
1704
|
+
publish();
|
|
1705
|
+
if (rollbackErrors.length > 0) {
|
|
1706
|
+
throw new AggregateError([startFailure, ...rollbackErrors], "[stitchkit] application startup and rollback failed", { cause: startFailure });
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
throw startFailure;
|
|
1710
|
+
};
|
|
1711
|
+
const start = () => {
|
|
1712
|
+
if (startPromise)
|
|
1713
|
+
return startPromise;
|
|
1714
|
+
if (lifecycle !== "created") {
|
|
1715
|
+
return Promise.reject(new Error(`[stitchkit] application cannot start from lifecycle "${lifecycle}"`));
|
|
1716
|
+
}
|
|
1717
|
+
startPromise = runStart();
|
|
1718
|
+
startPromise.catch(() => {
|
|
1719
|
+
return;
|
|
1720
|
+
});
|
|
1721
|
+
return startPromise;
|
|
1722
|
+
};
|
|
1723
|
+
const acquire = () => {
|
|
1724
|
+
if (!accepting || !isReady())
|
|
1725
|
+
return null;
|
|
1726
|
+
accepted += 1;
|
|
1727
|
+
pending += 1;
|
|
1728
|
+
publish();
|
|
1729
|
+
let released = false;
|
|
1730
|
+
return {
|
|
1731
|
+
get released() {
|
|
1732
|
+
return released;
|
|
1733
|
+
},
|
|
1734
|
+
release() {
|
|
1735
|
+
if (released)
|
|
1736
|
+
return;
|
|
1737
|
+
released = true;
|
|
1738
|
+
pending -= 1;
|
|
1739
|
+
completed += 1;
|
|
1740
|
+
if (pending === 0) {
|
|
1741
|
+
for (const waiter of pendingWaiters)
|
|
1742
|
+
waiter();
|
|
1743
|
+
pendingWaiters.clear();
|
|
1744
|
+
}
|
|
1745
|
+
publish();
|
|
1746
|
+
}
|
|
1747
|
+
};
|
|
1748
|
+
};
|
|
1749
|
+
const run = async (work) => {
|
|
1750
|
+
const lease = acquire();
|
|
1751
|
+
if (!lease)
|
|
1752
|
+
throw new ApplicationAdmissionError;
|
|
1753
|
+
try {
|
|
1754
|
+
return await work();
|
|
1755
|
+
} finally {
|
|
1756
|
+
lease.release();
|
|
1757
|
+
}
|
|
1758
|
+
};
|
|
1759
|
+
const waitForPending = () => {
|
|
1760
|
+
if (pending === 0)
|
|
1761
|
+
return Promise.resolve();
|
|
1762
|
+
return new Promise((resolve) => pendingWaiters.add(resolve));
|
|
1763
|
+
};
|
|
1764
|
+
const shutdown = (options) => {
|
|
1765
|
+
if (shutdownPromise)
|
|
1766
|
+
return shutdownPromise;
|
|
1767
|
+
const requested = options ?? {};
|
|
1768
|
+
const parsed = ApplicationShutdownOptionsSchema.parse({
|
|
1769
|
+
gracePeriodMs: requested.gracePeriodMs ?? shutdownBudget.gracePeriodMs,
|
|
1770
|
+
forceTimeoutMs: requested.forceTimeoutMs ?? shutdownBudget.forceTimeoutMs,
|
|
1771
|
+
...requested.signal !== undefined && { signal: requested.signal }
|
|
1772
|
+
});
|
|
1773
|
+
const startedAt = performance.now();
|
|
1774
|
+
const graceDeadlineAt = startedAt + parsed.gracePeriodMs;
|
|
1775
|
+
const forceDeadlineAt = graceDeadlineAt + parsed.forceTimeoutMs;
|
|
1776
|
+
shutdownRequested = true;
|
|
1777
|
+
accepting = false;
|
|
1778
|
+
startupAbort.abort();
|
|
1779
|
+
lifecycle = lifecycle === "created" ? "stopping" : "draining";
|
|
1780
|
+
publish();
|
|
1781
|
+
shutdownPromise = (async () => {
|
|
1782
|
+
const gracefulAbort = new AbortController;
|
|
1783
|
+
let forcedReason;
|
|
1784
|
+
let gracefulFailed = [...records.values()].some((record) => record.attempted && record.closeInvoked && !record.closed);
|
|
1785
|
+
const force = (reason) => {
|
|
1786
|
+
if (forcedReason)
|
|
1787
|
+
return;
|
|
1788
|
+
forcedReason = reason;
|
|
1789
|
+
gracefulAbort.abort();
|
|
1790
|
+
lifetimeAbort.abort();
|
|
1791
|
+
};
|
|
1792
|
+
const graceTimer = setTimeout(() => force("deadline"), Math.max(0, graceDeadlineAt - performance.now()));
|
|
1793
|
+
const onExternalAbort = () => force("signal");
|
|
1794
|
+
parsed.signal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
1795
|
+
if (parsed.signal?.aborted)
|
|
1796
|
+
force("signal");
|
|
1797
|
+
if (startPromise) {
|
|
1798
|
+
await untilDeadline(startPromise.catch(() => {
|
|
1799
|
+
return;
|
|
1800
|
+
}), gracefulAbort.signal);
|
|
1801
|
+
}
|
|
1802
|
+
const gracefulContext = (record) => contextFor(record, {
|
|
1803
|
+
signal: gracefulAbort.signal,
|
|
1804
|
+
deadlineAt: graceDeadlineAt,
|
|
1805
|
+
forceDeadlineAt
|
|
1806
|
+
});
|
|
1807
|
+
for (const entry of reverse) {
|
|
1808
|
+
if (gracefulAbort.signal.aborted)
|
|
1809
|
+
break;
|
|
1810
|
+
const record = records.get(entry.id);
|
|
1811
|
+
if (!record?.attempted || record.closed)
|
|
1812
|
+
continue;
|
|
1813
|
+
try {
|
|
1814
|
+
const result = await untilDeadline(Promise.resolve(entry.resource.stopAdmission?.(gracefulContext(record))), gracefulAbort.signal);
|
|
1815
|
+
if (!result.settled)
|
|
1816
|
+
break;
|
|
1817
|
+
if (result.error !== undefined)
|
|
1818
|
+
throw result.error;
|
|
1819
|
+
} catch (error) {
|
|
1820
|
+
record.failures.push("admission");
|
|
1821
|
+
reportFailure(entry.id, "admission", error);
|
|
1822
|
+
gracefulFailed = true;
|
|
1823
|
+
lifetimeAbort.abort();
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
if (!gracefulAbort.signal.aborted && !gracefulFailed) {
|
|
1827
|
+
const result = await untilDeadline(waitForPending(), gracefulAbort.signal);
|
|
1828
|
+
if (!result.settled)
|
|
1829
|
+
force("deadline");
|
|
1830
|
+
}
|
|
1831
|
+
for (const entry of reverse) {
|
|
1832
|
+
if (gracefulAbort.signal.aborted || gracefulFailed)
|
|
1833
|
+
break;
|
|
1834
|
+
const record = records.get(entry.id);
|
|
1835
|
+
if (!record?.attempted || record.closed)
|
|
1836
|
+
continue;
|
|
1837
|
+
record.state = "stopping";
|
|
1838
|
+
publish();
|
|
1839
|
+
try {
|
|
1840
|
+
const drained = await untilDeadline(Promise.resolve(entry.resource.drain?.(gracefulContext(record))), gracefulAbort.signal);
|
|
1841
|
+
if (!drained.settled) {
|
|
1842
|
+
force("deadline");
|
|
1843
|
+
break;
|
|
1844
|
+
}
|
|
1845
|
+
if (drained.error !== undefined)
|
|
1846
|
+
throw drained.error;
|
|
1847
|
+
} catch (error) {
|
|
1848
|
+
record.failures.push("drain");
|
|
1849
|
+
reportFailure(entry.id, "drain", error);
|
|
1850
|
+
gracefulFailed = true;
|
|
1851
|
+
lifetimeAbort.abort();
|
|
1852
|
+
break;
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
lifecycle = "stopping";
|
|
1856
|
+
publish();
|
|
1857
|
+
for (const entry of reverse) {
|
|
1858
|
+
if (gracefulAbort.signal.aborted || gracefulFailed)
|
|
1859
|
+
break;
|
|
1860
|
+
const record = records.get(entry.id);
|
|
1861
|
+
if (!record?.attempted || record.closed || record.closeInvoked)
|
|
1862
|
+
continue;
|
|
1863
|
+
try {
|
|
1864
|
+
record.closeInvoked = true;
|
|
1865
|
+
const closed = await untilDeadline(Promise.resolve(entry.resource.close?.(gracefulContext(record))), gracefulAbort.signal);
|
|
1866
|
+
if (!closed.settled) {
|
|
1867
|
+
force("deadline");
|
|
1868
|
+
break;
|
|
1869
|
+
}
|
|
1870
|
+
if (closed.error !== undefined)
|
|
1871
|
+
throw closed.error;
|
|
1872
|
+
record.closed = true;
|
|
1873
|
+
record.state = "stopped";
|
|
1874
|
+
publish();
|
|
1875
|
+
} catch (error) {
|
|
1876
|
+
record.failures.push("close");
|
|
1877
|
+
reportFailure(entry.id, "close", error);
|
|
1878
|
+
record.state = "failed";
|
|
1879
|
+
gracefulFailed = true;
|
|
1880
|
+
lifetimeAbort.abort();
|
|
1881
|
+
publish();
|
|
1882
|
+
break;
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
clearTimeout(graceTimer);
|
|
1886
|
+
parsed.signal?.removeEventListener("abort", onExternalAbort);
|
|
1887
|
+
const mustForce = forcedReason !== undefined || gracefulFailed;
|
|
1888
|
+
const pendingOperationsAtForce = mustForce ? pending : 0;
|
|
1889
|
+
if (mustForce) {
|
|
1890
|
+
lifetimeAbort.abort();
|
|
1891
|
+
const forceAbort = new AbortController;
|
|
1892
|
+
const forceTimer = setTimeout(() => forceAbort.abort(), Math.max(0, forceDeadlineAt - performance.now()));
|
|
1893
|
+
await Promise.all(reverse.map(async (entry) => {
|
|
1894
|
+
const record = records.get(entry.id);
|
|
1895
|
+
if (!record?.attempted || record.closed)
|
|
1896
|
+
return;
|
|
1897
|
+
try {
|
|
1898
|
+
let cleanup;
|
|
1899
|
+
if (entry.resource.force) {
|
|
1900
|
+
cleanup = Promise.resolve(entry.resource.force(contextFor(record, {
|
|
1901
|
+
signal: forceAbort.signal,
|
|
1902
|
+
deadlineAt: graceDeadlineAt,
|
|
1903
|
+
forceDeadlineAt
|
|
1904
|
+
})));
|
|
1905
|
+
} else if (entry.resource.close && !record.closeInvoked) {
|
|
1906
|
+
record.closeInvoked = true;
|
|
1907
|
+
cleanup = Promise.resolve(entry.resource.close(contextFor(record, {
|
|
1908
|
+
signal: forceAbort.signal,
|
|
1909
|
+
deadlineAt: graceDeadlineAt,
|
|
1910
|
+
forceDeadlineAt
|
|
1911
|
+
})));
|
|
1912
|
+
} else if (entry.resource.close) {
|
|
1913
|
+
record.failures.push("force");
|
|
1914
|
+
reportFailure(entry.id, "force", new Error(`[stitchkit] resource "${entry.id}" was already closing and did not settle before the force deadline`));
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
const forced = await untilDeadline(cleanup ?? Promise.resolve(), forceAbort.signal);
|
|
1918
|
+
if (!forced.settled || forced.error !== undefined) {
|
|
1919
|
+
record.failures.push("force");
|
|
1920
|
+
reportFailure(entry.id, "force", forced.settled ? forced.error : new Error("[stitchkit] forced cleanup did not settle in time"));
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
record.closed = true;
|
|
1924
|
+
record.state = "stopped";
|
|
1925
|
+
} catch (error) {
|
|
1926
|
+
record.failures.push("force");
|
|
1927
|
+
reportFailure(entry.id, "force", error);
|
|
1928
|
+
}
|
|
1929
|
+
}));
|
|
1930
|
+
clearTimeout(forceTimer);
|
|
1931
|
+
}
|
|
1932
|
+
const cleanupComplete = [...records.values()].every((record) => !record.attempted || record.closed);
|
|
1933
|
+
lifecycle = cleanupComplete ? "stopped" : "failed";
|
|
1934
|
+
publish();
|
|
1935
|
+
return ApplicationShutdownResultSchema.parse({
|
|
1936
|
+
outcome: mustForce ? "forced" : "clean",
|
|
1937
|
+
...forcedReason && { reason: forcedReason },
|
|
1938
|
+
cleanupComplete,
|
|
1939
|
+
acceptedOperations: accepted,
|
|
1940
|
+
completedOperations: completed,
|
|
1941
|
+
pendingOperations: pending,
|
|
1942
|
+
pendingOperationsAtForce,
|
|
1943
|
+
resources: ordered.map((entry) => {
|
|
1944
|
+
const record = records.get(entry.id);
|
|
1945
|
+
if (!record)
|
|
1946
|
+
throw new Error("Managed resource record disappeared");
|
|
1947
|
+
return {
|
|
1948
|
+
id: entry.id,
|
|
1949
|
+
state: !record.attempted ? "not-started" : record.closed ? "closed" : "force-failed",
|
|
1950
|
+
failures: record.failures
|
|
1951
|
+
};
|
|
1952
|
+
}),
|
|
1953
|
+
durationMs: performance.now() - startedAt
|
|
1954
|
+
});
|
|
1955
|
+
})();
|
|
1956
|
+
shutdownPromise.catch(() => {
|
|
1957
|
+
return;
|
|
1958
|
+
});
|
|
1959
|
+
return shutdownPromise;
|
|
1960
|
+
};
|
|
1961
|
+
return {
|
|
1962
|
+
id,
|
|
1963
|
+
admission: { acquire, run },
|
|
1964
|
+
start,
|
|
1965
|
+
getSnapshot: snapshot,
|
|
1966
|
+
subscribe(listener) {
|
|
1967
|
+
listeners.add(listener);
|
|
1968
|
+
listener(snapshot());
|
|
1969
|
+
return () => listeners.delete(listener);
|
|
1970
|
+
},
|
|
1971
|
+
shutdown,
|
|
1972
|
+
restart(input) {
|
|
1973
|
+
const queued = restarting.then(() => runRestart(input));
|
|
1974
|
+
restarting = queued.then(() => {
|
|
1975
|
+
return;
|
|
1976
|
+
}, () => {
|
|
1977
|
+
return;
|
|
1978
|
+
});
|
|
1979
|
+
return queued;
|
|
1980
|
+
}
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1282
1983
|
// src/application/keyspace.ts
|
|
1283
1984
|
function defineKeyspace(name, declaration) {
|
|
1284
1985
|
if (name.trim() === "") {
|
|
@@ -1302,7 +2003,7 @@ function keyspaceResource(declaration, config) {
|
|
|
1302
2003
|
id: config.id ?? `keyspace:${declaration.name}`,
|
|
1303
2004
|
...config.dependsOn && { dependsOn: config.dependsOn },
|
|
1304
2005
|
async start(_context) {
|
|
1305
|
-
await machine.
|
|
2006
|
+
await machine.open();
|
|
1306
2007
|
return { value: machine.keyspace };
|
|
1307
2008
|
},
|
|
1308
2009
|
stopAdmission: machine.stopAdmission,
|
|
@@ -1315,9 +2016,14 @@ function keyspaceMachine(declaration, config) {
|
|
|
1315
2016
|
const maxPending = config.maxPendingWrites ?? 1024;
|
|
1316
2017
|
const records = new Map;
|
|
1317
2018
|
let admitting = true;
|
|
2019
|
+
let backend;
|
|
2020
|
+
const currentBackend = () => {
|
|
2021
|
+
backend ??= typeof config.backend === "function" ? config.backend() : config.backend;
|
|
2022
|
+
return backend;
|
|
2023
|
+
};
|
|
1318
2024
|
let pending = 0;
|
|
1319
2025
|
let chain = Promise.resolve();
|
|
1320
|
-
function
|
|
2026
|
+
function run(work) {
|
|
1321
2027
|
if (!admitting) {
|
|
1322
2028
|
return Promise.reject(new Error(`[stitchkit] keyspace "${declaration.name}" is shutting down and is not accepting writes.`));
|
|
1323
2029
|
}
|
|
@@ -1344,8 +2050,8 @@ function keyspaceMachine(declaration, config) {
|
|
|
1344
2050
|
put(value) {
|
|
1345
2051
|
const parsed = declaration.schema.parse(value);
|
|
1346
2052
|
const key = declaration.key(parsed);
|
|
1347
|
-
return
|
|
1348
|
-
await
|
|
2053
|
+
return run(async () => {
|
|
2054
|
+
await currentBackend().put(key, parsed);
|
|
1349
2055
|
records.set(key, parsed);
|
|
1350
2056
|
config.onChanged?.({
|
|
1351
2057
|
keyspace: declaration.name,
|
|
@@ -1356,8 +2062,8 @@ function keyspaceMachine(declaration, config) {
|
|
|
1356
2062
|
});
|
|
1357
2063
|
},
|
|
1358
2064
|
delete(key) {
|
|
1359
|
-
return
|
|
1360
|
-
await
|
|
2065
|
+
return run(async () => {
|
|
2066
|
+
await currentBackend().delete(key);
|
|
1361
2067
|
records.delete(key);
|
|
1362
2068
|
config.onChanged?.({ keyspace: declaration.name, key, change: "delete" });
|
|
1363
2069
|
});
|
|
@@ -1371,11 +2077,19 @@ function keyspaceMachine(declaration, config) {
|
|
|
1371
2077
|
return {
|
|
1372
2078
|
keyspace,
|
|
1373
2079
|
async load() {
|
|
1374
|
-
for (const stored of await
|
|
2080
|
+
for (const stored of await currentBackend().load()) {
|
|
1375
2081
|
const value = declaration.schema.parse(stored);
|
|
1376
2082
|
records.set(declaration.key(value), value);
|
|
1377
2083
|
}
|
|
1378
2084
|
},
|
|
2085
|
+
async open() {
|
|
2086
|
+
backend = undefined;
|
|
2087
|
+
records.clear();
|
|
2088
|
+
chain = Promise.resolve();
|
|
2089
|
+
pending = 0;
|
|
2090
|
+
admitting = true;
|
|
2091
|
+
await this.load();
|
|
2092
|
+
},
|
|
1379
2093
|
stopAdmission() {
|
|
1380
2094
|
admitting = false;
|
|
1381
2095
|
},
|
|
@@ -1384,7 +2098,7 @@ function keyspaceMachine(declaration, config) {
|
|
|
1384
2098
|
},
|
|
1385
2099
|
async close() {
|
|
1386
2100
|
reportUnwritten();
|
|
1387
|
-
await
|
|
2101
|
+
await backend?.close?.();
|
|
1388
2102
|
},
|
|
1389
2103
|
force() {
|
|
1390
2104
|
reportUnwritten();
|
|
@@ -1434,6 +2148,308 @@ function sqliteKeyspaceBackend(declaration, config) {
|
|
|
1434
2148
|
}
|
|
1435
2149
|
};
|
|
1436
2150
|
}
|
|
2151
|
+
// src/application/schedule.ts
|
|
2152
|
+
import { z as z11 } from "zod";
|
|
2153
|
+
var NonNegativeSafeIntegerSchema = z11.number().int().nonnegative().refine(Number.isSafeInteger, "Expected a safe integer");
|
|
2154
|
+
var PositiveSafeIntegerSchema3 = NonNegativeSafeIntegerSchema.refine((value) => value > 0, "Expected a positive integer");
|
|
2155
|
+
var ManagedScheduleOverlapSchema = z11.discriminatedUnion("mode", [
|
|
2156
|
+
z11.object({ mode: z11.literal("skip") }).readonly(),
|
|
2157
|
+
z11.object({ mode: z11.literal("queue-one") }).readonly(),
|
|
2158
|
+
z11.object({
|
|
2159
|
+
mode: z11.literal("parallel"),
|
|
2160
|
+
maxConcurrent: PositiveSafeIntegerSchema3
|
|
2161
|
+
}).readonly()
|
|
2162
|
+
]);
|
|
2163
|
+
var ManagedScheduleErrorPolicySchema = z11.enum(["continue", "stop-schedule"]);
|
|
2164
|
+
var ManagedScheduleDescriptorSchema = z11.object({
|
|
2165
|
+
id: ApplicationIdSchema,
|
|
2166
|
+
everyMs: PositiveSafeIntegerSchema3,
|
|
2167
|
+
startAfterMs: NonNegativeSafeIntegerSchema,
|
|
2168
|
+
overlap: ManagedScheduleOverlapSchema,
|
|
2169
|
+
errorPolicy: ManagedScheduleErrorPolicySchema
|
|
2170
|
+
}).readonly();
|
|
2171
|
+
var ManagedScheduleStatusSchema = z11.object({
|
|
2172
|
+
descriptor: ManagedScheduleDescriptorSchema,
|
|
2173
|
+
state: z11.enum(["inactive", "scheduled", "running", "draining", "stopped"]),
|
|
2174
|
+
revision: NonNegativeSafeIntegerSchema,
|
|
2175
|
+
capturedAt: z11.string().datetime({ offset: true }),
|
|
2176
|
+
changedAt: z11.string().datetime({ offset: true }),
|
|
2177
|
+
accepting: z11.boolean(),
|
|
2178
|
+
active: NonNegativeSafeIntegerSchema,
|
|
2179
|
+
queued: z11.boolean(),
|
|
2180
|
+
runsStarted: NonNegativeSafeIntegerSchema,
|
|
2181
|
+
runsCompleted: NonNegativeSafeIntegerSchema,
|
|
2182
|
+
runsFailed: NonNegativeSafeIntegerSchema,
|
|
2183
|
+
ticksSkipped: NonNegativeSafeIntegerSchema,
|
|
2184
|
+
nextRunAt: z11.string().datetime({ offset: true }).nullable(),
|
|
2185
|
+
lastScheduledAt: z11.string().datetime({ offset: true }).nullable(),
|
|
2186
|
+
lastStartedAt: z11.string().datetime({ offset: true }).nullable(),
|
|
2187
|
+
lastFinishedAt: z11.string().datetime({ offset: true }).nullable()
|
|
2188
|
+
}).readonly();
|
|
2189
|
+
var systemClock = {
|
|
2190
|
+
now: () => performance.now(),
|
|
2191
|
+
wallNow: () => new Date,
|
|
2192
|
+
schedule(callback, delayMs) {
|
|
2193
|
+
const timer = setTimeout(callback, delayMs);
|
|
2194
|
+
return { cancel: () => clearTimeout(timer) };
|
|
2195
|
+
}
|
|
2196
|
+
};
|
|
2197
|
+
var DEFAULT_OVERLAP = { mode: "skip" };
|
|
2198
|
+
function createManagedSchedule(config) {
|
|
2199
|
+
const descriptor = ManagedScheduleDescriptorSchema.parse({
|
|
2200
|
+
id: config.id,
|
|
2201
|
+
everyMs: config.everyMs,
|
|
2202
|
+
startAfterMs: config.startAfterMs ?? config.everyMs,
|
|
2203
|
+
overlap: config.overlap ?? DEFAULT_OVERLAP,
|
|
2204
|
+
errorPolicy: config.errorPolicy ?? "continue"
|
|
2205
|
+
});
|
|
2206
|
+
const dependsOn = config.dependsOn?.map((id) => ApplicationIdSchema.parse(id));
|
|
2207
|
+
const clock = config.clock ?? systemClock;
|
|
2208
|
+
let revision = 0;
|
|
2209
|
+
let accepting = false;
|
|
2210
|
+
let activated = false;
|
|
2211
|
+
let stopped = false;
|
|
2212
|
+
let timer = null;
|
|
2213
|
+
let activationContext = null;
|
|
2214
|
+
let nextRunAt = null;
|
|
2215
|
+
let queuedAt = null;
|
|
2216
|
+
let runsStarted = 0;
|
|
2217
|
+
let runsCompleted = 0;
|
|
2218
|
+
let runsFailed = 0;
|
|
2219
|
+
let ticksSkipped = 0;
|
|
2220
|
+
let lastScheduledAt = null;
|
|
2221
|
+
let lastStartedAt = null;
|
|
2222
|
+
let lastFinishedAt = null;
|
|
2223
|
+
let changedAt = clock.wallNow().toISOString();
|
|
2224
|
+
const active = new Set;
|
|
2225
|
+
const changed = () => {
|
|
2226
|
+
revision += 1;
|
|
2227
|
+
changedAt = clock.wallNow().toISOString();
|
|
2228
|
+
};
|
|
2229
|
+
const cancelFuture = () => {
|
|
2230
|
+
timer?.cancel();
|
|
2231
|
+
timer = null;
|
|
2232
|
+
nextRunAt = null;
|
|
2233
|
+
queuedAt = null;
|
|
2234
|
+
};
|
|
2235
|
+
const stopAdmission = () => {
|
|
2236
|
+
if (!accepting && stopped)
|
|
2237
|
+
return;
|
|
2238
|
+
accepting = false;
|
|
2239
|
+
stopped = true;
|
|
2240
|
+
cancelFuture();
|
|
2241
|
+
changed();
|
|
2242
|
+
};
|
|
2243
|
+
const reportError = (error, context) => {
|
|
2244
|
+
if (!config.onError)
|
|
2245
|
+
return;
|
|
2246
|
+
Promise.resolve().then(() => config.onError?.(error, context)).catch(() => {});
|
|
2247
|
+
};
|
|
2248
|
+
const state = () => {
|
|
2249
|
+
if (!activated)
|
|
2250
|
+
return stopped ? "stopped" : "inactive";
|
|
2251
|
+
if (!accepting)
|
|
2252
|
+
return active.size > 0 ? "draining" : "stopped";
|
|
2253
|
+
return active.size > 0 ? "running" : "scheduled";
|
|
2254
|
+
};
|
|
2255
|
+
const wallAt = (monotonicAt, anchor) => {
|
|
2256
|
+
if (monotonicAt === null)
|
|
2257
|
+
return null;
|
|
2258
|
+
return new Date(anchor.wallNow + monotonicAt - anchor.monotonicNow).toISOString();
|
|
2259
|
+
};
|
|
2260
|
+
const status = () => {
|
|
2261
|
+
const captured = clock.wallNow();
|
|
2262
|
+
const anchor = { monotonicNow: clock.now(), wallNow: captured.getTime() };
|
|
2263
|
+
return ManagedScheduleStatusSchema.parse({
|
|
2264
|
+
descriptor,
|
|
2265
|
+
state: state(),
|
|
2266
|
+
revision,
|
|
2267
|
+
capturedAt: captured.toISOString(),
|
|
2268
|
+
changedAt,
|
|
2269
|
+
accepting,
|
|
2270
|
+
active: active.size,
|
|
2271
|
+
queued: queuedAt !== null,
|
|
2272
|
+
runsStarted,
|
|
2273
|
+
runsCompleted,
|
|
2274
|
+
runsFailed,
|
|
2275
|
+
ticksSkipped,
|
|
2276
|
+
nextRunAt: wallAt(nextRunAt, anchor),
|
|
2277
|
+
lastScheduledAt,
|
|
2278
|
+
lastStartedAt,
|
|
2279
|
+
lastFinishedAt
|
|
2280
|
+
});
|
|
2281
|
+
};
|
|
2282
|
+
let startExecution = () => {
|
|
2283
|
+
return;
|
|
2284
|
+
};
|
|
2285
|
+
const settleExecution = () => {
|
|
2286
|
+
if (!accepting || queuedAt === null || active.size !== 0)
|
|
2287
|
+
return;
|
|
2288
|
+
const successorAt = queuedAt;
|
|
2289
|
+
queuedAt = null;
|
|
2290
|
+
changed();
|
|
2291
|
+
startExecution(successorAt);
|
|
2292
|
+
};
|
|
2293
|
+
startExecution = (scheduledAt) => {
|
|
2294
|
+
const resourceContext = activationContext;
|
|
2295
|
+
if (!accepting || !resourceContext)
|
|
2296
|
+
return;
|
|
2297
|
+
const startedAt = clock.now();
|
|
2298
|
+
const wallStartedAt = clock.wallNow().getTime();
|
|
2299
|
+
const runContext = {
|
|
2300
|
+
applicationId: resourceContext.applicationId,
|
|
2301
|
+
signal: resourceContext.signal,
|
|
2302
|
+
scheduledAt,
|
|
2303
|
+
startedAt,
|
|
2304
|
+
now: () => clock.now()
|
|
2305
|
+
};
|
|
2306
|
+
runsStarted += 1;
|
|
2307
|
+
lastScheduledAt = new Date(wallStartedAt + scheduledAt - startedAt).toISOString();
|
|
2308
|
+
lastStartedAt = new Date(wallStartedAt).toISOString();
|
|
2309
|
+
changed();
|
|
2310
|
+
let tracked;
|
|
2311
|
+
tracked = Promise.resolve().then(() => config.run(runContext)).then(() => {
|
|
2312
|
+
runsCompleted += 1;
|
|
2313
|
+
lastFinishedAt = clock.wallNow().toISOString();
|
|
2314
|
+
if (descriptor.errorPolicy === "continue" && accepting) {
|
|
2315
|
+
resourceContext.reportHealth("healthy");
|
|
2316
|
+
}
|
|
2317
|
+
}, (error) => {
|
|
2318
|
+
runsFailed += 1;
|
|
2319
|
+
lastFinishedAt = clock.wallNow().toISOString();
|
|
2320
|
+
reportError(error, runContext);
|
|
2321
|
+
if (descriptor.errorPolicy === "stop-schedule") {
|
|
2322
|
+
resourceContext.reportHealth("unhealthy");
|
|
2323
|
+
stopAdmission();
|
|
2324
|
+
} else {
|
|
2325
|
+
resourceContext.reportHealth("degraded");
|
|
2326
|
+
}
|
|
2327
|
+
}).finally(() => {
|
|
2328
|
+
active.delete(tracked);
|
|
2329
|
+
changed();
|
|
2330
|
+
settleExecution();
|
|
2331
|
+
});
|
|
2332
|
+
active.add(tracked);
|
|
2333
|
+
};
|
|
2334
|
+
const dispatchTick = (scheduledAt) => {
|
|
2335
|
+
if (!accepting)
|
|
2336
|
+
return;
|
|
2337
|
+
if (descriptor.overlap.mode === "skip") {
|
|
2338
|
+
if (active.size > 0) {
|
|
2339
|
+
ticksSkipped += 1;
|
|
2340
|
+
changed();
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
startExecution(scheduledAt);
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
if (descriptor.overlap.mode === "queue-one") {
|
|
2347
|
+
if (active.size > 0) {
|
|
2348
|
+
queuedAt = scheduledAt;
|
|
2349
|
+
changed();
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
startExecution(scheduledAt);
|
|
2353
|
+
return;
|
|
2354
|
+
}
|
|
2355
|
+
if (active.size >= descriptor.overlap.maxConcurrent) {
|
|
2356
|
+
ticksSkipped += 1;
|
|
2357
|
+
changed();
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
startExecution(scheduledAt);
|
|
2361
|
+
};
|
|
2362
|
+
const arm = () => {
|
|
2363
|
+
if (!accepting || nextRunAt === null)
|
|
2364
|
+
return;
|
|
2365
|
+
const delayMs = Math.max(0, nextRunAt - clock.now());
|
|
2366
|
+
timer = clock.schedule(() => {
|
|
2367
|
+
timer = null;
|
|
2368
|
+
if (!accepting || nextRunAt === null)
|
|
2369
|
+
return;
|
|
2370
|
+
const scheduledAt = nextRunAt;
|
|
2371
|
+
const observedAt = clock.now();
|
|
2372
|
+
const elapsedIntervals = Math.max(1, Math.floor((observedAt - scheduledAt) / descriptor.everyMs) + 1);
|
|
2373
|
+
if (elapsedIntervals > 1) {
|
|
2374
|
+
ticksSkipped += elapsedIntervals - 1;
|
|
2375
|
+
changed();
|
|
2376
|
+
}
|
|
2377
|
+
nextRunAt = scheduledAt + elapsedIntervals * descriptor.everyMs;
|
|
2378
|
+
arm();
|
|
2379
|
+
dispatchTick(scheduledAt);
|
|
2380
|
+
}, delayMs);
|
|
2381
|
+
};
|
|
2382
|
+
const waitForActive = async (context, deadlineAt) => {
|
|
2383
|
+
if (active.size === 0 || context.signal.aborted)
|
|
2384
|
+
return;
|
|
2385
|
+
await new Promise((resolve) => {
|
|
2386
|
+
let settled = false;
|
|
2387
|
+
let deadlineTimer = null;
|
|
2388
|
+
const finish = () => {
|
|
2389
|
+
if (settled)
|
|
2390
|
+
return;
|
|
2391
|
+
settled = true;
|
|
2392
|
+
deadlineTimer?.cancel();
|
|
2393
|
+
context.signal.removeEventListener("abort", finish);
|
|
2394
|
+
resolve();
|
|
2395
|
+
};
|
|
2396
|
+
context.signal.addEventListener("abort", finish, { once: true });
|
|
2397
|
+
if (deadlineAt !== undefined) {
|
|
2398
|
+
deadlineTimer = clock.schedule(finish, Math.max(0, deadlineAt - context.now()));
|
|
2399
|
+
}
|
|
2400
|
+
Promise.allSettled([...active]).then(finish);
|
|
2401
|
+
});
|
|
2402
|
+
};
|
|
2403
|
+
const drain = async (context) => {
|
|
2404
|
+
stopAdmission();
|
|
2405
|
+
await waitForActive(context, context.deadlineAt);
|
|
2406
|
+
};
|
|
2407
|
+
return {
|
|
2408
|
+
id: descriptor.id,
|
|
2409
|
+
...dependsOn && { dependsOn },
|
|
2410
|
+
...config.required !== undefined && { required: config.required },
|
|
2411
|
+
get status() {
|
|
2412
|
+
return status();
|
|
2413
|
+
},
|
|
2414
|
+
start() {
|
|
2415
|
+
if (active.size > 0) {
|
|
2416
|
+
throw new Error(`[stitchkit] schedule "${descriptor.id}" still has executions from its previous generation`);
|
|
2417
|
+
}
|
|
2418
|
+
cancelFuture();
|
|
2419
|
+
stopped = false;
|
|
2420
|
+
accepting = false;
|
|
2421
|
+
activated = false;
|
|
2422
|
+
activationContext = null;
|
|
2423
|
+
queuedAt = null;
|
|
2424
|
+
changed();
|
|
2425
|
+
},
|
|
2426
|
+
activate(context) {
|
|
2427
|
+
if (activated || stopped)
|
|
2428
|
+
return;
|
|
2429
|
+
activated = true;
|
|
2430
|
+
accepting = true;
|
|
2431
|
+
activationContext = context;
|
|
2432
|
+
nextRunAt = clock.now() + descriptor.startAfterMs;
|
|
2433
|
+
context.reportHealth("healthy");
|
|
2434
|
+
changed();
|
|
2435
|
+
arm();
|
|
2436
|
+
},
|
|
2437
|
+
stopAdmission() {
|
|
2438
|
+
stopAdmission();
|
|
2439
|
+
},
|
|
2440
|
+
drain,
|
|
2441
|
+
close() {
|
|
2442
|
+
stopAdmission();
|
|
2443
|
+
},
|
|
2444
|
+
async force(context) {
|
|
2445
|
+
stopAdmission();
|
|
2446
|
+
await waitForActive(context, context.forceDeadlineAt);
|
|
2447
|
+
if (active.size > 0) {
|
|
2448
|
+
throw new Error(`[stitchkit] schedule "${descriptor.id}" still has active executions`);
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
1437
2453
|
// src/application/server-resource.ts
|
|
1438
2454
|
function managedServerResource(config) {
|
|
1439
2455
|
let shutdownPromise;
|
|
@@ -1475,6 +2491,11 @@ function managedServerResource(config) {
|
|
|
1475
2491
|
...config.dependsOn && { dependsOn: config.dependsOn },
|
|
1476
2492
|
...config.required !== undefined && { required: config.required },
|
|
1477
2493
|
async start(context) {
|
|
2494
|
+
if (startAttempted && typeof config.server !== "function") {
|
|
2495
|
+
throw new Error(`[stitchkit] managed server "${config.id}" cannot be restarted: it was given a server instance, and that one has been shut down. Pass a factory — \`server: (context) => …\` — so each generation gets its own.`);
|
|
2496
|
+
}
|
|
2497
|
+
shutdownPromise = undefined;
|
|
2498
|
+
started = undefined;
|
|
1478
2499
|
startAttempted = true;
|
|
1479
2500
|
started = await resolveServer(context);
|
|
1480
2501
|
return { value: started };
|
|
@@ -1493,109 +2514,6 @@ function managedServerResource(config) {
|
|
|
1493
2514
|
}
|
|
1494
2515
|
});
|
|
1495
2516
|
}
|
|
1496
|
-
// src/browser/resumable.ts
|
|
1497
|
-
import { z as z7 } from "zod";
|
|
1498
|
-
var PositiveSafeIntegerSchema3 = z7.number().int().positive().safe();
|
|
1499
|
-
var BackoffPolicySchema = z7.object({
|
|
1500
|
-
minDelayMs: PositiveSafeIntegerSchema3,
|
|
1501
|
-
maxDelayMs: PositiveSafeIntegerSchema3,
|
|
1502
|
-
jitter: z7.number().min(0).max(1)
|
|
1503
|
-
}).strict().readonly().refine((policy) => policy.maxDelayMs >= policy.minDelayMs, {
|
|
1504
|
-
message: "maxDelayMs must be at least minDelayMs"
|
|
1505
|
-
});
|
|
1506
|
-
function createBackoff(policy, random = Math.random) {
|
|
1507
|
-
const { minDelayMs, maxDelayMs, jitter } = BackoffPolicySchema.parse(policy);
|
|
1508
|
-
let attempt = 0;
|
|
1509
|
-
return {
|
|
1510
|
-
next() {
|
|
1511
|
-
const exponential = Math.min(maxDelayMs, minDelayMs * 2 ** attempt);
|
|
1512
|
-
attempt += 1;
|
|
1513
|
-
const spread = exponential * jitter * random();
|
|
1514
|
-
return Math.max(1, Math.round(exponential - spread));
|
|
1515
|
-
},
|
|
1516
|
-
reset() {
|
|
1517
|
-
attempt = 0;
|
|
1518
|
-
}
|
|
1519
|
-
};
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
// src/live/watch-contract.ts
|
|
1523
|
-
import { z as z9 } from "zod";
|
|
1524
|
-
|
|
1525
|
-
// src/browser/live-state.ts
|
|
1526
|
-
import { z as z8 } from "zod";
|
|
1527
|
-
var PositiveSafeIntegerSchema4 = z8.number().int().positive().safe();
|
|
1528
|
-
var LiveStatePhaseSchema = z8.enum([
|
|
1529
|
-
"idle",
|
|
1530
|
-
"opening",
|
|
1531
|
-
"live",
|
|
1532
|
-
"resync-required",
|
|
1533
|
-
"unavailable",
|
|
1534
|
-
"closed"
|
|
1535
|
-
]);
|
|
1536
|
-
var LiveStateStopReasonSchema = z8.enum([
|
|
1537
|
-
"gap",
|
|
1538
|
-
"buffer-overflow",
|
|
1539
|
-
"source-unavailable",
|
|
1540
|
-
"source-error",
|
|
1541
|
-
"controller-error",
|
|
1542
|
-
"controller-capacity"
|
|
1543
|
-
]);
|
|
1544
|
-
var LiveStateControllerStatusSchema = z8.object({
|
|
1545
|
-
phase: LiveStatePhaseSchema,
|
|
1546
|
-
generation: z8.number().int().nonnegative(),
|
|
1547
|
-
hasValue: z8.boolean(),
|
|
1548
|
-
bufferedEvents: z8.number().int().nonnegative(),
|
|
1549
|
-
bufferedBytes: z8.number().int().nonnegative(),
|
|
1550
|
-
receivedEvents: z8.number().int().nonnegative(),
|
|
1551
|
-
appliedEvents: z8.number().int().nonnegative(),
|
|
1552
|
-
duplicateEvents: z8.number().int().nonnegative(),
|
|
1553
|
-
gapEvents: z8.number().int().nonnegative(),
|
|
1554
|
-
refusedEvents: z8.number().int().nonnegative(),
|
|
1555
|
-
reason: LiveStateStopReasonSchema.optional()
|
|
1556
|
-
}).strict().readonly();
|
|
1557
|
-
|
|
1558
|
-
// src/live/watch-contract.ts
|
|
1559
|
-
var WatchKeySchema = z9.object({
|
|
1560
|
-
service: z9.string().min(1),
|
|
1561
|
-
action: z9.string().min(1),
|
|
1562
|
-
digest: z9.string().min(1)
|
|
1563
|
-
}).strict().readonly();
|
|
1564
|
-
var WatchOpenSchema = z9.object({ key: WatchKeySchema, args: z9.unknown() }).readonly();
|
|
1565
|
-
var WatchAcceptedSchema = z9.object({
|
|
1566
|
-
accepted: z9.boolean(),
|
|
1567
|
-
reason: z9.string().optional()
|
|
1568
|
-
}).strict().readonly();
|
|
1569
|
-
var WatchValueSchema = z9.object({
|
|
1570
|
-
key: WatchKeySchema,
|
|
1571
|
-
revision: z9.number().int().nonnegative(),
|
|
1572
|
-
value: z9.unknown()
|
|
1573
|
-
}).readonly();
|
|
1574
|
-
var WatchStateSchema = z9.object({
|
|
1575
|
-
key: WatchKeySchema,
|
|
1576
|
-
phase: LiveStatePhaseSchema,
|
|
1577
|
-
reason: LiveStateStopReasonSchema.optional(),
|
|
1578
|
-
code: z9.string().optional(),
|
|
1579
|
-
message: z9.string().optional()
|
|
1580
|
-
}).readonly();
|
|
1581
|
-
var WATCH_OPEN = "stitchkit.watch.open";
|
|
1582
|
-
var WATCH_CLOSE = "stitchkit.watch.close";
|
|
1583
|
-
var WATCH_VALUE = "stitchkit.watch.value";
|
|
1584
|
-
var WATCH_STATE = "stitchkit.watch.state";
|
|
1585
|
-
var watchContract = {
|
|
1586
|
-
serverToClient: {
|
|
1587
|
-
[WATCH_VALUE]: { args: z9.tuple([WatchValueSchema]) },
|
|
1588
|
-
[WATCH_STATE]: { args: z9.tuple([WatchStateSchema]) }
|
|
1589
|
-
},
|
|
1590
|
-
clientToServer: {
|
|
1591
|
-
[WATCH_OPEN]: { args: z9.tuple([WatchOpenSchema]), ack: WatchAcceptedSchema },
|
|
1592
|
-
[WATCH_CLOSE]: { args: z9.tuple([z9.object({ key: WatchKeySchema }).readonly()]) }
|
|
1593
|
-
}
|
|
1594
|
-
};
|
|
1595
|
-
function watchKeyString(key) {
|
|
1596
|
-
return `${key.service}/${key.action}/${key.digest}`;
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
2517
|
// src/application/watch-hub.ts
|
|
1600
2518
|
var DEFAULT_BACKOFF = { minDelayMs: 250, maxDelayMs: 30000, jitter: 0.2 };
|
|
1601
2519
|
function watchKey(operation, args) {
|
|
@@ -1819,7 +2737,6 @@ export {
|
|
|
1819
2737
|
defineKeyspace,
|
|
1820
2738
|
createWatchHub,
|
|
1821
2739
|
createManagedSchedule,
|
|
1822
|
-
createDiagnosticJournal,
|
|
1823
2740
|
createDecisionPipeline,
|
|
1824
2741
|
createCreditWindow,
|
|
1825
2742
|
createBoundedChannel,
|