stitchkit 0.76.2 → 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 +89 -0
- package/dist/application/decisions.d.ts.map +1 -0
- package/dist/application/kernel.d.ts +35 -1
- 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.d.ts +15 -0
- package/dist/application-schemas.d.ts.map +1 -0
- package/dist/application-schemas.js +26 -0
- package/dist/application.d.ts +3 -3
- package/dist/application.d.ts.map +1 -1
- package/dist/application.js +1832 -844
- 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/internal/decision.d.ts +31 -0
- package/dist/internal/decision.d.ts.map +1 -0
- package/dist/live/events.d.ts +8 -13
- package/dist/live/events.d.ts.map +1 -1
- package/dist/live.d.ts +1 -1
- package/dist/live.d.ts.map +1 -1
- 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 +3 -2
- 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 +276 -6
- package/package.json +12 -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-hvftzz91.js +0 -721
- 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,43 +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
|
-
ApplicationShutdownBudgetSchema,
|
|
34
|
-
ApplicationShutdownOptionsSchema,
|
|
35
|
-
createApplication
|
|
36
|
-
} from "./index-hvftzz91.js";
|
|
37
|
-
import {
|
|
38
|
-
defineManagedResource,
|
|
39
|
-
managedResourceDependencyId
|
|
40
|
-
} from "./index-2k4yrqkc.js";
|
|
22
|
+
isRecord
|
|
23
|
+
} from "./index-ksp6e2ye.js";
|
|
41
24
|
import {
|
|
42
25
|
ApplicationAdmissionSnapshotSchema,
|
|
43
26
|
ApplicationHealthSchema,
|
|
@@ -50,46 +33,315 @@ import {
|
|
|
50
33
|
ManagedResourceSnapshotSchema,
|
|
51
34
|
ManagedResourceStateSchema,
|
|
52
35
|
projectApplicationStatus
|
|
53
|
-
} from "./index-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
import {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// 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
|
|
60
42
|
import { z } from "zod";
|
|
61
|
-
var
|
|
62
|
-
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({
|
|
63
315
|
limit: PositiveSafeIntegerSchema,
|
|
64
316
|
intervalMs: PositiveSafeIntegerSchema
|
|
65
317
|
}).strict().readonly();
|
|
66
|
-
var BoundedAdmissionPerKeyLimitsSchema =
|
|
318
|
+
var BoundedAdmissionPerKeyLimitsSchema = z3.object({
|
|
67
319
|
maxConcurrent: PositiveSafeIntegerSchema,
|
|
68
320
|
rate: BoundedRateBudgetSchema.optional()
|
|
69
321
|
}).strict().readonly();
|
|
70
|
-
var PerKeyLimitResolverSchema =
|
|
71
|
-
var BoundedAdmissionPolicySchema =
|
|
72
|
-
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({
|
|
73
325
|
maxConcurrent: PositiveSafeIntegerSchema,
|
|
74
326
|
rate: BoundedRateBudgetSchema.optional()
|
|
75
327
|
}).strict(),
|
|
76
|
-
perKey:
|
|
77
|
-
|
|
328
|
+
perKey: z3.union([
|
|
329
|
+
z3.object({
|
|
78
330
|
maxConcurrent: PositiveSafeIntegerSchema,
|
|
79
331
|
maxKeys: PositiveSafeIntegerSchema,
|
|
80
332
|
rate: BoundedRateBudgetSchema.optional(),
|
|
81
|
-
limits:
|
|
333
|
+
limits: z3.never().optional()
|
|
82
334
|
}).strict(),
|
|
83
|
-
|
|
335
|
+
z3.object({
|
|
84
336
|
maxKeys: PositiveSafeIntegerSchema,
|
|
85
337
|
limits: PerKeyLimitResolverSchema,
|
|
86
|
-
maxConcurrent:
|
|
87
|
-
rate:
|
|
338
|
+
maxConcurrent: z3.never().optional(),
|
|
339
|
+
rate: z3.never().optional()
|
|
88
340
|
}).strict()
|
|
89
341
|
]).optional()
|
|
90
342
|
}).strict().readonly();
|
|
91
|
-
var BoundedAdmissionStateSchema =
|
|
92
|
-
var BoundedAdmissionRefusalReasonSchema =
|
|
343
|
+
var BoundedAdmissionStateSchema = z3.enum(["accepting", "draining", "closed"]);
|
|
344
|
+
var BoundedAdmissionRefusalReasonSchema = z3.enum([
|
|
93
345
|
"not-accepting",
|
|
94
346
|
"global-concurrency",
|
|
95
347
|
"key-required",
|
|
@@ -99,25 +351,25 @@ var BoundedAdmissionRefusalReasonSchema = z.enum([
|
|
|
99
351
|
"key-rate",
|
|
100
352
|
"upstream"
|
|
101
353
|
]);
|
|
102
|
-
var RefusalCountersSchema =
|
|
103
|
-
"not-accepting":
|
|
104
|
-
"global-concurrency":
|
|
105
|
-
"key-required":
|
|
106
|
-
"key-capacity":
|
|
107
|
-
"key-concurrency":
|
|
108
|
-
"global-rate":
|
|
109
|
-
"key-rate":
|
|
110
|
-
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()
|
|
111
363
|
}).strict().readonly();
|
|
112
|
-
var BoundedAdmissionSnapshotSchema =
|
|
364
|
+
var BoundedAdmissionSnapshotSchema = z3.object({
|
|
113
365
|
state: BoundedAdmissionStateSchema,
|
|
114
|
-
active:
|
|
115
|
-
accepted:
|
|
116
|
-
released:
|
|
117
|
-
refused:
|
|
118
|
-
keys:
|
|
119
|
-
globalRateSamples:
|
|
120
|
-
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(),
|
|
121
373
|
refusals: RefusalCountersSchema
|
|
122
374
|
}).strict().readonly();
|
|
123
375
|
|
|
@@ -430,23 +682,117 @@ function createBoundedAdmission(config) {
|
|
|
430
682
|
getSnapshot
|
|
431
683
|
};
|
|
432
684
|
}
|
|
433
|
-
// src/
|
|
434
|
-
|
|
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
|
+
|
|
704
|
+
// src/internal/decision.ts
|
|
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()
|
|
710
|
+
]);
|
|
711
|
+
|
|
712
|
+
// src/application/decisions.ts
|
|
713
|
+
class DecisionUndecidedError extends Error {
|
|
714
|
+
trace;
|
|
715
|
+
constructor(trace) {
|
|
716
|
+
const ran = trace.length === 0 ? "no policy ran" : trace.map((e) => e.id).join(" → ");
|
|
717
|
+
super(`Decision pipeline reached its end with no terminal verdict (${ran}). A pipeline needs a policy that answers allow or deny.`);
|
|
718
|
+
this.name = "DecisionUndecidedError";
|
|
719
|
+
this.trace = trace;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
435
722
|
|
|
723
|
+
class DecisionPolicyError extends Error {
|
|
724
|
+
policyId;
|
|
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.`);
|
|
728
|
+
this.name = "DecisionPolicyError";
|
|
729
|
+
this.policyId = policyId;
|
|
730
|
+
this.trace = trace;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
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
|
+
}
|
|
738
|
+
const seen = new Set;
|
|
739
|
+
for (const policy of policies) {
|
|
740
|
+
if (policy.id.trim() === "") {
|
|
741
|
+
throw new Error("[stitchkit] decision pipeline: a policy needs a non-empty id.");
|
|
742
|
+
}
|
|
743
|
+
if (seen.has(policy.id)) {
|
|
744
|
+
throw new Error(`[stitchkit] decision pipeline: two policies share the id "${policy.id}". The trace names policies by id, and a duplicate makes it ambiguous exactly when it is read to explain a refusal.`);
|
|
745
|
+
}
|
|
746
|
+
seen.add(policy.id);
|
|
747
|
+
}
|
|
748
|
+
return {
|
|
749
|
+
policyIds: policies.map((policy) => policy.id),
|
|
750
|
+
async decide(input) {
|
|
751
|
+
const trace = [];
|
|
752
|
+
for (const policy of policies) {
|
|
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
|
+
}
|
|
765
|
+
const parsed = PolicyDecisionSchema.safeParse(answer);
|
|
766
|
+
if (!parsed.success) {
|
|
767
|
+
throw new DecisionPolicyError(policy.id, parsed.error.issues[0]?.message ?? "invalid", trace);
|
|
768
|
+
}
|
|
769
|
+
const decision = parsed.data;
|
|
770
|
+
trace.push(decision.outcome === "deny" ? { id: policy.id, outcome: "deny", reason: decision.reason } : { id: policy.id, outcome: decision.outcome });
|
|
771
|
+
if (decision.outcome === "deny") {
|
|
772
|
+
return { outcome: "deny", reason: decision.reason, trace };
|
|
773
|
+
}
|
|
774
|
+
if (decision.outcome === "allow") {
|
|
775
|
+
return { outcome: "allow", trace };
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
throw new DecisionUndecidedError(trace);
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
}
|
|
436
782
|
// src/application/diagnostic-journal-contract.ts
|
|
437
|
-
import { z as
|
|
438
|
-
var PositiveSafeIntegerSchema2 =
|
|
439
|
-
var FileModeSchema =
|
|
440
|
-
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({
|
|
441
787
|
maxEventBytes: PositiveSafeIntegerSchema2,
|
|
442
788
|
maxPendingItems: PositiveSafeIntegerSchema2,
|
|
443
789
|
maxPendingBytes: PositiveSafeIntegerSchema2,
|
|
444
790
|
maxFileBytes: PositiveSafeIntegerSchema2,
|
|
445
791
|
maxFiles: PositiveSafeIntegerSchema2
|
|
446
792
|
}).strict().readonly();
|
|
447
|
-
var DiagnosticJournalLockPolicySchema =
|
|
448
|
-
var DiagnosticJournalStateSchema =
|
|
449
|
-
var DiagnosticJournalRefusalReasonSchema =
|
|
793
|
+
var DiagnosticJournalLockPolicySchema = z5.enum(["refuse", "reclaim-stale"]);
|
|
794
|
+
var DiagnosticJournalStateSchema = z5.enum(["open", "draining", "closed", "failed"]);
|
|
795
|
+
var DiagnosticJournalRefusalReasonSchema = z5.enum([
|
|
450
796
|
"closed",
|
|
451
797
|
"failed",
|
|
452
798
|
"invalid",
|
|
@@ -454,704 +800,228 @@ var DiagnosticJournalRefusalReasonSchema = z2.enum([
|
|
|
454
800
|
"item-capacity",
|
|
455
801
|
"byte-capacity"
|
|
456
802
|
]);
|
|
457
|
-
var DiagnosticJournalRefusalCountersSchema =
|
|
458
|
-
closed:
|
|
459
|
-
failed:
|
|
460
|
-
invalid:
|
|
461
|
-
oversized:
|
|
462
|
-
"item-capacity":
|
|
463
|
-
"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()
|
|
464
810
|
}).strict().readonly();
|
|
465
|
-
var DiagnosticJournalFailurePhaseSchema =
|
|
466
|
-
var DiagnosticJournalStatusSchema =
|
|
811
|
+
var DiagnosticJournalFailurePhaseSchema = z5.enum(["write", "rotation", "close"]);
|
|
812
|
+
var DiagnosticJournalStatusSchema = z5.object({
|
|
467
813
|
state: DiagnosticJournalStateSchema,
|
|
468
|
-
epoch:
|
|
814
|
+
epoch: z5.uuid(),
|
|
469
815
|
limits: DiagnosticJournalLimitsSchema,
|
|
470
|
-
lock:
|
|
816
|
+
lock: z5.object({
|
|
471
817
|
policy: DiagnosticJournalLockPolicySchema,
|
|
472
|
-
reclaimedStale:
|
|
818
|
+
reclaimedStale: z5.boolean()
|
|
473
819
|
}).strict().readonly(),
|
|
474
|
-
received:
|
|
475
|
-
accepted:
|
|
476
|
-
refused:
|
|
820
|
+
received: z5.number().int().nonnegative(),
|
|
821
|
+
accepted: z5.number().int().nonnegative(),
|
|
822
|
+
refused: z5.number().int().nonnegative(),
|
|
477
823
|
refusals: DiagnosticJournalRefusalCountersSchema,
|
|
478
|
-
written:
|
|
479
|
-
failedRecords:
|
|
480
|
-
pendingItems:
|
|
481
|
-
pendingBytes:
|
|
482
|
-
inFlight:
|
|
483
|
-
rotations:
|
|
484
|
-
rotationFailures:
|
|
485
|
-
partialTails:
|
|
486
|
-
currentFileBytes:
|
|
487
|
-
retainedFiles:
|
|
488
|
-
lastAcceptedSequence:
|
|
489
|
-
lastWrittenSequence:
|
|
490
|
-
lastSettledSequence:
|
|
491
|
-
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({
|
|
492
838
|
phase: DiagnosticJournalFailurePhaseSchema,
|
|
493
|
-
sequence:
|
|
839
|
+
sequence: z5.number().int().positive().optional()
|
|
494
840
|
}).strict().readonly().optional()
|
|
495
841
|
}).strict().readonly();
|
|
496
|
-
var DiagnosticJournalSubmitResultSchema =
|
|
497
|
-
|
|
498
|
-
outcome:
|
|
499
|
-
epoch:
|
|
500
|
-
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()
|
|
501
847
|
}).strict().readonly(),
|
|
502
|
-
|
|
503
|
-
outcome:
|
|
848
|
+
z5.object({
|
|
849
|
+
outcome: z5.literal("refused"),
|
|
504
850
|
reason: DiagnosticJournalRefusalReasonSchema
|
|
505
851
|
}).strict().readonly()
|
|
506
852
|
]);
|
|
507
|
-
var DiagnosticJournalWaitResultSchema =
|
|
508
|
-
outcome:
|
|
853
|
+
var DiagnosticJournalWaitResultSchema = z5.object({
|
|
854
|
+
outcome: z5.enum(["settled", "timed-out", "cancelled"]),
|
|
509
855
|
state: DiagnosticJournalStateSchema,
|
|
510
|
-
throughSequence:
|
|
511
|
-
settledSequence:
|
|
856
|
+
throughSequence: z5.number().int().nonnegative(),
|
|
857
|
+
settledSequence: z5.number().int().nonnegative()
|
|
512
858
|
}).strict().readonly();
|
|
513
|
-
var DiagnosticJournalCloseResultSchema =
|
|
514
|
-
outcome:
|
|
859
|
+
var DiagnosticJournalCloseResultSchema = z5.object({
|
|
860
|
+
outcome: z5.enum(["closed", "timed-out", "cancelled"]),
|
|
515
861
|
state: DiagnosticJournalStateSchema,
|
|
516
|
-
pendingItems:
|
|
862
|
+
pendingItems: z5.number().int().nonnegative()
|
|
517
863
|
}).strict().readonly();
|
|
518
|
-
var DiagnosticJournalFrameSchema =
|
|
519
|
-
schemaVersion:
|
|
520
|
-
epoch:
|
|
521
|
-
sequence:
|
|
522
|
-
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()
|
|
523
869
|
}).strict().readonly();
|
|
524
|
-
function parseDiagnosticJournalMode(mode) {
|
|
525
|
-
return FileModeSchema.parse(mode ?? 384);
|
|
526
|
-
}
|
|
527
870
|
function readDiagnosticJournalLockDiagnosis(error) {
|
|
528
871
|
if (!isRecord(error))
|
|
529
872
|
return;
|
|
530
873
|
const diagnosis = error.journalLock;
|
|
531
874
|
return isRecord(diagnosis) ? diagnosis : undefined;
|
|
532
875
|
}
|
|
876
|
+
// src/application/events.ts
|
|
877
|
+
import { z as z7 } from "zod";
|
|
533
878
|
|
|
534
|
-
// src/
|
|
535
|
-
import { z as
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
return;
|
|
560
|
-
const { pid, host, acquiredAt, machine } = parsed;
|
|
561
|
-
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0)
|
|
562
|
-
return;
|
|
563
|
-
if (typeof host !== "string" || host.length === 0)
|
|
564
|
-
return;
|
|
565
|
-
if (typeof acquiredAt !== "string")
|
|
566
|
-
return;
|
|
567
|
-
const identified = typeof machine === "string" && machine.length > 0 ? { machine } : {};
|
|
568
|
-
return { pid, host, acquiredAt, ...identified };
|
|
569
|
-
}
|
|
570
|
-
var detectedIdentity;
|
|
571
|
-
async function firstReadableLine(paths) {
|
|
572
|
-
for (const path of paths) {
|
|
573
|
-
try {
|
|
574
|
-
const text = (await readFile(path, "utf8")).trim();
|
|
575
|
-
if (text.length > 0)
|
|
576
|
-
return text;
|
|
577
|
-
} catch {}
|
|
578
|
-
}
|
|
579
|
-
return null;
|
|
580
|
-
}
|
|
581
|
-
var run = promisify(execFile);
|
|
582
|
-
function parsePlatformUuid(dump) {
|
|
583
|
-
return /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(dump)?.[1] ?? null;
|
|
584
|
-
}
|
|
585
|
-
async function readDarwinPlatformUuid(command = "ioreg") {
|
|
586
|
-
try {
|
|
587
|
-
const { stdout } = await run(command, ["-rd1", "-c", "IOPlatformExpertDevice"], {
|
|
588
|
-
timeout: 2000,
|
|
589
|
-
maxBuffer: 1 << 20,
|
|
590
|
-
encoding: "utf8"
|
|
591
|
-
});
|
|
592
|
-
return parsePlatformUuid(stdout);
|
|
593
|
-
} catch {
|
|
594
|
-
return null;
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
async function machineIdentity(declared) {
|
|
598
|
-
if (declared !== undefined)
|
|
599
|
-
return declared;
|
|
600
|
-
if (detectedIdentity !== undefined)
|
|
601
|
-
return detectedIdentity;
|
|
602
|
-
detectedIdentity = platform() === "darwin" ? await readDarwinPlatformUuid() : await firstReadableLine(["/etc/machine-id", "/var/lib/dbus/machine-id"]);
|
|
603
|
-
return detectedIdentity;
|
|
604
|
-
}
|
|
605
|
-
function attribute(owner, identity) {
|
|
606
|
-
if (identity !== null && owner.machine !== undefined) {
|
|
607
|
-
return owner.machine === identity ? "this-machine" : "another-machine";
|
|
608
|
-
}
|
|
609
|
-
return owner.host === hostname() ? "this-machine" : "unattributable";
|
|
610
|
-
}
|
|
611
|
-
async function probeLiveness(owner) {
|
|
612
|
-
try {
|
|
613
|
-
process.kill(owner.pid, 0);
|
|
614
|
-
} catch (error) {
|
|
615
|
-
return isRecord(error) && error.code === "ESRCH" ? "gone" : "alive";
|
|
616
|
-
}
|
|
617
|
-
return await isZombieProcess(owner.pid) ? "gone" : "alive";
|
|
618
|
-
}
|
|
619
|
-
async function isZombieProcess(pid, seam = {}) {
|
|
620
|
-
const { procRoot = "/proc", psCommand = "ps" } = seam;
|
|
621
|
-
try {
|
|
622
|
-
const stat = await readFile(`${procRoot}/${pid}/stat`, "utf8");
|
|
623
|
-
return stat.slice(stat.lastIndexOf(")") + 1).trimStart().startsWith("Z");
|
|
624
|
-
} catch {}
|
|
625
|
-
try {
|
|
626
|
-
const { stdout } = await run(psCommand, ["-o", "state=", "-p", String(pid)], {
|
|
627
|
-
timeout: 2000,
|
|
628
|
-
maxBuffer: 1 << 16,
|
|
629
|
-
encoding: "utf8"
|
|
630
|
-
});
|
|
631
|
-
return stdout.trim().startsWith("Z");
|
|
632
|
-
} catch {
|
|
633
|
-
return false;
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
async function diagnose(owner, declaredIdentity) {
|
|
637
|
-
if (!owner)
|
|
638
|
-
return { attribution: "unattributable", liveness: "not-probed", owner: null };
|
|
639
|
-
const attribution = attribute(owner, await machineIdentity(declaredIdentity));
|
|
640
|
-
const record = {
|
|
641
|
-
pid: owner.pid,
|
|
642
|
-
host: owner.host,
|
|
643
|
-
acquiredAt: owner.acquiredAt,
|
|
644
|
-
...owner.machine !== undefined && { machine: owner.machine }
|
|
645
|
-
};
|
|
646
|
-
if (attribution !== "this-machine")
|
|
647
|
-
return { attribution, liveness: "not-probed", owner: record };
|
|
648
|
-
return { attribution, liveness: await probeLiveness(owner), owner: record };
|
|
649
|
-
}
|
|
650
|
-
async function readLockOwner(lockPath) {
|
|
651
|
-
let handle;
|
|
652
|
-
try {
|
|
653
|
-
handle = await open(lockPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
654
|
-
return readOwner(await handle.readFile("utf8"));
|
|
655
|
-
} catch {
|
|
656
|
-
return;
|
|
657
|
-
} finally {
|
|
658
|
-
await handle?.close().catch(() => {
|
|
659
|
-
return;
|
|
660
|
-
});
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
async function createExclusive(lockPath, mode, declaredIdentity) {
|
|
664
|
-
const handle = await open(lockPath, "wx", mode);
|
|
665
|
-
const identity = await machineIdentity(declaredIdentity);
|
|
666
|
-
const owner = {
|
|
667
|
-
pid: process.pid,
|
|
668
|
-
host: hostname(),
|
|
669
|
-
acquiredAt: new Date().toISOString(),
|
|
670
|
-
...identity !== null && { machine: identity }
|
|
671
|
-
};
|
|
672
|
-
await handle.writeFile(`${JSON.stringify(owner)}
|
|
673
|
-
`, "utf8");
|
|
674
|
-
return handle;
|
|
675
|
-
}
|
|
676
|
-
async function acquireDiagnosticJournalLock(lockPath, mode, policy, declaredIdentity) {
|
|
677
|
-
try {
|
|
678
|
-
return {
|
|
679
|
-
handle: await createExclusive(lockPath, mode, declaredIdentity),
|
|
680
|
-
reclaimedStale: false
|
|
681
|
-
};
|
|
682
|
-
} catch (error) {
|
|
683
|
-
if (policy !== "reclaim-stale" || !isExisting(error))
|
|
684
|
-
throw error;
|
|
685
|
-
const owner = await readLockOwner(lockPath);
|
|
686
|
-
const diagnosis = await diagnose(owner, declaredIdentity);
|
|
687
|
-
if (!owner || diagnosis.liveness !== "gone") {
|
|
688
|
-
if (isRecord(error))
|
|
689
|
-
Object.assign(error, { journalLock: diagnosis });
|
|
690
|
-
throw error;
|
|
691
|
-
}
|
|
692
|
-
await unlink(lockPath).catch(() => {
|
|
693
|
-
return;
|
|
694
|
-
});
|
|
695
|
-
return {
|
|
696
|
-
handle: await createExclusive(lockPath, mode, declaredIdentity),
|
|
697
|
-
reclaimedStale: true
|
|
698
|
-
};
|
|
699
|
-
}
|
|
700
|
-
}
|
|
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();
|
|
701
904
|
|
|
702
|
-
// src/
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
super(message, options);
|
|
707
|
-
this.phase = phase;
|
|
708
|
-
this.name = "DiagnosticJournalStorageError";
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
function isMissing(error) {
|
|
712
|
-
return isRecord(error) && error.code === "ENOENT";
|
|
713
|
-
}
|
|
714
|
-
async function removeIfPresent(path) {
|
|
715
|
-
try {
|
|
716
|
-
const info = await lstat(path);
|
|
717
|
-
if (info.isSymbolicLink() || !info.isFile()) {
|
|
718
|
-
throw new Error("Diagnostic journal generations must be regular files");
|
|
719
|
-
}
|
|
720
|
-
await unlink2(path);
|
|
721
|
-
} catch (error) {
|
|
722
|
-
if (!isMissing(error))
|
|
723
|
-
throw error;
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
async function moveIfPresent(from, to) {
|
|
727
|
-
try {
|
|
728
|
-
const info = await lstat(from);
|
|
729
|
-
if (info.isSymbolicLink() || !info.isFile()) {
|
|
730
|
-
throw new Error("Diagnostic journal generations must be regular files");
|
|
731
|
-
}
|
|
732
|
-
await rename(from, to);
|
|
733
|
-
return true;
|
|
734
|
-
} catch (error) {
|
|
735
|
-
if (isMissing(error))
|
|
736
|
-
return false;
|
|
737
|
-
throw error;
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
function generation(path, index) {
|
|
741
|
-
return `${path}.${index}`;
|
|
742
|
-
}
|
|
743
|
-
function generationIndex(name, prefix) {
|
|
744
|
-
if (!name.startsWith(prefix))
|
|
905
|
+
// src/internal/observability-sink.ts
|
|
906
|
+
var DEFAULT_MAX_PENDING = 1000;
|
|
907
|
+
function invokeIsolated(callback) {
|
|
908
|
+
if (!callback)
|
|
745
909
|
return;
|
|
746
|
-
|
|
747
|
-
if (!/^\d+$/.test(suffix))
|
|
748
|
-
return;
|
|
749
|
-
const value = Number(suffix);
|
|
750
|
-
return Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
910
|
+
Promise.resolve().then(callback).catch(() => {});
|
|
751
911
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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");
|
|
755
916
|
}
|
|
756
|
-
|
|
757
|
-
const journalPath = resolve(parent, basename(config.path));
|
|
758
|
-
const lockPath = `${journalPath}.lock`;
|
|
759
|
-
const { handle: lock, reclaimedStale } = await acquireDiagnosticJournalLock(lockPath, config.mode, config.lock, config.machineIdentity);
|
|
760
|
-
let handle;
|
|
761
|
-
let currentFileBytes = 0;
|
|
762
|
-
let retainedFiles = 1;
|
|
763
|
-
let rotations = 0;
|
|
764
|
-
let partialTails = 0;
|
|
917
|
+
let sequence = 0;
|
|
765
918
|
let closed = false;
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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);
|
|
775
931
|
};
|
|
776
|
-
const
|
|
932
|
+
const reportDrop = (reason, event) => {
|
|
933
|
+
invokeIsolated(config.onDrop ? () => config.onDrop?.({ reason, event, pending: writes.size }) : undefined);
|
|
934
|
+
};
|
|
935
|
+
const admit = (id, event) => {
|
|
777
936
|
try {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
await removeIfPresent(journalPath);
|
|
782
|
-
} else {
|
|
783
|
-
await removeIfPresent(generation(journalPath, config.maxFiles - 1));
|
|
784
|
-
for (let index = config.maxFiles - 2;index >= 1; index -= 1) {
|
|
785
|
-
await moveIfPresent(generation(journalPath, index), generation(journalPath, index + 1));
|
|
786
|
-
}
|
|
787
|
-
await moveIfPresent(journalPath, generation(journalPath, 1));
|
|
937
|
+
if (config.filter && !config.filter(event)) {
|
|
938
|
+
filtered += 1;
|
|
939
|
+
return;
|
|
788
940
|
}
|
|
789
|
-
retainedFiles = Math.min(config.maxFiles, retainedFiles + 1);
|
|
790
|
-
rotations += 1;
|
|
791
|
-
await openCurrent();
|
|
792
941
|
} catch (error) {
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
};
|
|
796
|
-
try {
|
|
797
|
-
const names = await readdir(parent);
|
|
798
|
-
const prefix = `${basename(journalPath)}.`;
|
|
799
|
-
let existingGenerations = 0;
|
|
800
|
-
for (const name of names) {
|
|
801
|
-
const index = generationIndex(name, prefix);
|
|
802
|
-
if (index === undefined)
|
|
803
|
-
continue;
|
|
804
|
-
if (index >= config.maxFiles) {
|
|
805
|
-
await removeIfPresent(resolve(parent, name));
|
|
806
|
-
} else {
|
|
807
|
-
const info = await lstat(resolve(parent, name));
|
|
808
|
-
if (info.isSymbolicLink() || !info.isFile()) {
|
|
809
|
-
throw new Error("Diagnostic journal generations must be regular files");
|
|
810
|
-
}
|
|
811
|
-
existingGenerations += 1;
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
retainedFiles = Math.min(config.maxFiles, existingGenerations + 1);
|
|
815
|
-
await openCurrent();
|
|
816
|
-
if (currentFileBytes > 0) {
|
|
817
|
-
const tail = new Uint8Array(1);
|
|
818
|
-
const read = await handle?.read(tail, 0, 1, currentFileBytes - 1);
|
|
819
|
-
if (read?.bytesRead === 1 && tail[0] !== 10) {
|
|
820
|
-
partialTails += 1;
|
|
821
|
-
await rotate();
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
} catch (error) {
|
|
825
|
-
await handle?.close().catch(() => {
|
|
942
|
+
preparationFailed += 1;
|
|
943
|
+
reportError(error, event);
|
|
826
944
|
return;
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
await unlink2(lockPath).catch(() => {
|
|
945
|
+
}
|
|
946
|
+
if (writes.size >= maxPending) {
|
|
947
|
+
dropped += 1;
|
|
948
|
+
reportDrop("capacity", event);
|
|
832
949
|
return;
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
+
};
|
|
842
965
|
return {
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
try {
|
|
854
|
-
await handle.writeFile(bytes);
|
|
855
|
-
currentFileBytes += bytes.byteLength;
|
|
856
|
-
return { ...snapshot(), rotated };
|
|
857
|
-
} catch (error) {
|
|
858
|
-
throw new DiagnosticJournalStorageError("write", "Diagnostic journal write failed", {
|
|
859
|
-
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);
|
|
860
976
|
});
|
|
977
|
+
return;
|
|
861
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);
|
|
862
984
|
},
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
985
|
+
flush() {
|
|
986
|
+
return awaitGeneration(sequence);
|
|
987
|
+
},
|
|
988
|
+
getStatus,
|
|
989
|
+
close() {
|
|
990
|
+
if (closePromise)
|
|
991
|
+
return closePromise;
|
|
867
992
|
closed = true;
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
await handle?.close();
|
|
871
|
-
} catch (error) {
|
|
872
|
-
failure = error;
|
|
873
|
-
}
|
|
874
|
-
try {
|
|
875
|
-
await lock.close();
|
|
876
|
-
await unlink2(lockPath);
|
|
877
|
-
} catch (error) {
|
|
878
|
-
failure ??= error;
|
|
879
|
-
}
|
|
880
|
-
if (failure !== undefined) {
|
|
881
|
-
throw new DiagnosticJournalStorageError("close", "Diagnostic journal close failed", {
|
|
882
|
-
cause: failure
|
|
883
|
-
});
|
|
884
|
-
}
|
|
993
|
+
closePromise = awaitGeneration(sequence).then(getStatus);
|
|
994
|
+
return closePromise;
|
|
885
995
|
}
|
|
886
996
|
};
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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
|
+
});
|
|
895
1011
|
}
|
|
896
|
-
return options.timeoutMs;
|
|
897
|
-
}
|
|
898
|
-
function createDiagnosticJournalManager(config, storage) {
|
|
899
|
-
const limits = DiagnosticJournalLimitsSchema.parse(config.limits);
|
|
900
|
-
const encoder = new TextEncoder;
|
|
901
|
-
const channel = createBoundedChannel({
|
|
902
|
-
policy: "ordered",
|
|
903
|
-
maxItems: limits.maxPendingItems,
|
|
904
|
-
maxBytes: limits.maxPendingBytes,
|
|
905
|
-
sizeOf: (frame) => frame.bytes.byteLength
|
|
906
|
-
});
|
|
907
|
-
const refusals = {
|
|
908
|
-
closed: 0,
|
|
909
|
-
failed: 0,
|
|
910
|
-
invalid: 0,
|
|
911
|
-
oversized: 0,
|
|
912
|
-
"item-capacity": 0,
|
|
913
|
-
"byte-capacity": 0
|
|
914
|
-
};
|
|
915
|
-
const progressWaiters = new Set;
|
|
916
|
-
let state = "open";
|
|
917
|
-
let received = 0;
|
|
918
|
-
let accepted = 0;
|
|
919
|
-
let refused = 0;
|
|
920
|
-
let written = 0;
|
|
921
|
-
let failedRecords = 0;
|
|
922
|
-
let pendingItems = 0;
|
|
923
|
-
let pendingBytes = 0;
|
|
924
|
-
let inFlight = false;
|
|
925
|
-
let rotationFailures = 0;
|
|
926
|
-
let lastAcceptedSequence = 0;
|
|
927
|
-
let lastWrittenSequence = 0;
|
|
928
|
-
let lastSettledSequence = 0;
|
|
929
|
-
let lastFailure;
|
|
930
|
-
let physicalClosed = false;
|
|
931
|
-
const isFailed = () => state === "failed";
|
|
932
|
-
const notifyProgress = () => {
|
|
933
|
-
for (const waiter of progressWaiters)
|
|
934
|
-
waiter();
|
|
935
|
-
};
|
|
936
|
-
const reportFailure = (failure) => {
|
|
937
|
-
if (!config.onFailure)
|
|
938
|
-
return;
|
|
939
|
-
Promise.resolve().then(() => config.onFailure?.(failure)).catch(() => {
|
|
940
|
-
return;
|
|
941
|
-
});
|
|
942
|
-
};
|
|
943
|
-
const markFailure = (error, fallbackPhase, sequence) => {
|
|
944
|
-
const phase = error instanceof DiagnosticJournalStorageError ? error.phase : fallbackPhase;
|
|
945
|
-
if (phase === "rotation")
|
|
946
|
-
rotationFailures += 1;
|
|
947
|
-
lastFailure = { phase, ...sequence !== undefined && { sequence } };
|
|
948
|
-
state = "failed";
|
|
949
|
-
channel.close({ mode: "drain" });
|
|
950
|
-
reportFailure({ phase, error, ...sequence !== undefined && { sequence } });
|
|
951
|
-
};
|
|
952
|
-
const worker = (async () => {
|
|
953
|
-
for await (const frame of channel) {
|
|
954
|
-
inFlight = true;
|
|
955
|
-
if (isFailed()) {
|
|
956
|
-
failedRecords += 1;
|
|
957
|
-
} else {
|
|
958
|
-
try {
|
|
959
|
-
await storage.append(frame.bytes);
|
|
960
|
-
written += 1;
|
|
961
|
-
lastWrittenSequence = frame.sequence;
|
|
962
|
-
} catch (error) {
|
|
963
|
-
failedRecords += 1;
|
|
964
|
-
markFailure(error, "write", frame.sequence);
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
pendingItems -= 1;
|
|
968
|
-
pendingBytes -= frame.bytes.byteLength;
|
|
969
|
-
lastSettledSequence = frame.sequence;
|
|
970
|
-
inFlight = false;
|
|
971
|
-
notifyProgress();
|
|
972
|
-
}
|
|
973
|
-
try {
|
|
974
|
-
await storage.close();
|
|
975
|
-
} catch (error) {
|
|
976
|
-
markFailure(error, "close");
|
|
977
|
-
}
|
|
978
|
-
physicalClosed = true;
|
|
979
|
-
if (!isFailed())
|
|
980
|
-
state = "closed";
|
|
981
|
-
notifyProgress();
|
|
982
|
-
})();
|
|
983
|
-
worker.catch((error) => {
|
|
984
|
-
markFailure(error, "close");
|
|
985
|
-
physicalClosed = true;
|
|
986
|
-
notifyProgress();
|
|
987
|
-
});
|
|
988
|
-
const refuse = (reason) => {
|
|
989
|
-
refused += 1;
|
|
990
|
-
refusals[reason] += 1;
|
|
991
|
-
return DiagnosticJournalSubmitResultSchema.parse({ outcome: "refused", reason });
|
|
992
|
-
};
|
|
993
|
-
const waitUntil = (predicate, options) => {
|
|
994
|
-
const timeoutMs = waitBudget(options);
|
|
995
|
-
if (predicate())
|
|
996
|
-
return Promise.resolve("settled");
|
|
997
|
-
if (options.signal?.aborted)
|
|
998
|
-
return Promise.resolve("cancelled");
|
|
999
|
-
return new Promise((resolve2) => {
|
|
1000
|
-
let settled = false;
|
|
1001
|
-
let timer;
|
|
1002
|
-
const finish = (outcome) => {
|
|
1003
|
-
if (settled)
|
|
1004
|
-
return;
|
|
1005
|
-
settled = true;
|
|
1006
|
-
progressWaiters.delete(onProgress);
|
|
1007
|
-
options.signal?.removeEventListener("abort", onAbort);
|
|
1008
|
-
if (timer)
|
|
1009
|
-
clearTimeout(timer);
|
|
1010
|
-
resolve2(outcome);
|
|
1011
|
-
};
|
|
1012
|
-
const onProgress = () => {
|
|
1013
|
-
if (predicate())
|
|
1014
|
-
finish("settled");
|
|
1015
|
-
};
|
|
1016
|
-
const onAbort = () => finish("cancelled");
|
|
1017
|
-
progressWaiters.add(onProgress);
|
|
1018
|
-
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1019
|
-
if (timeoutMs !== undefined)
|
|
1020
|
-
timer = setTimeout(() => finish("timed-out"), timeoutMs);
|
|
1021
|
-
onProgress();
|
|
1022
|
-
});
|
|
1023
|
-
};
|
|
1024
|
-
const getStatus = () => {
|
|
1025
|
-
const file = storage.snapshot();
|
|
1026
|
-
return DiagnosticJournalStatusSchema.parse({
|
|
1027
|
-
state,
|
|
1028
|
-
epoch: config.epoch,
|
|
1029
|
-
limits,
|
|
1030
|
-
lock: config.lock,
|
|
1031
|
-
received,
|
|
1032
|
-
accepted,
|
|
1033
|
-
refused,
|
|
1034
|
-
refusals,
|
|
1035
|
-
written,
|
|
1036
|
-
failedRecords,
|
|
1037
|
-
pendingItems,
|
|
1038
|
-
pendingBytes,
|
|
1039
|
-
inFlight,
|
|
1040
|
-
rotations: file.rotations,
|
|
1041
|
-
rotationFailures,
|
|
1042
|
-
partialTails: file.partialTails,
|
|
1043
|
-
currentFileBytes: file.currentFileBytes,
|
|
1044
|
-
retainedFiles: file.retainedFiles,
|
|
1045
|
-
...lastAcceptedSequence > 0 && { lastAcceptedSequence },
|
|
1046
|
-
...lastWrittenSequence > 0 && { lastWrittenSequence },
|
|
1047
|
-
...lastSettledSequence > 0 && { lastSettledSequence },
|
|
1048
|
-
...lastFailure && { lastFailure }
|
|
1049
|
-
});
|
|
1050
|
-
};
|
|
1051
|
-
return {
|
|
1052
|
-
submit(event) {
|
|
1053
|
-
received += 1;
|
|
1054
|
-
if (state === "failed")
|
|
1055
|
-
return refuse("failed");
|
|
1056
|
-
if (state !== "open")
|
|
1057
|
-
return refuse("closed");
|
|
1058
|
-
const parsed = config.eventSchema.safeParse(event);
|
|
1059
|
-
if (!parsed.success)
|
|
1060
|
-
return refuse("invalid");
|
|
1061
|
-
const json = z3.json().safeParse(parsed.data);
|
|
1062
|
-
if (!json.success)
|
|
1063
|
-
return refuse("invalid");
|
|
1064
|
-
const payload = encoder.encode(JSON.stringify(json.data));
|
|
1065
|
-
if (payload.byteLength > limits.maxEventBytes)
|
|
1066
|
-
return refuse("oversized");
|
|
1067
|
-
if (pendingItems >= limits.maxPendingItems)
|
|
1068
|
-
return refuse("item-capacity");
|
|
1069
|
-
const sequence = lastAcceptedSequence + 1;
|
|
1070
|
-
const frame = DiagnosticJournalFrameSchema.parse({
|
|
1071
|
-
schemaVersion: 1,
|
|
1072
|
-
epoch: config.epoch,
|
|
1073
|
-
sequence,
|
|
1074
|
-
event: json.data
|
|
1075
|
-
});
|
|
1076
|
-
const bytes = encoder.encode(`${JSON.stringify(frame)}
|
|
1077
|
-
`);
|
|
1078
|
-
if (bytes.byteLength > limits.maxFileBytes)
|
|
1079
|
-
return refuse("oversized");
|
|
1080
|
-
if (pendingBytes + bytes.byteLength > limits.maxPendingBytes) {
|
|
1081
|
-
return refuse("byte-capacity");
|
|
1082
|
-
}
|
|
1083
|
-
const offered = channel.offer({ sequence, bytes });
|
|
1084
|
-
if (offered.outcome === "refused") {
|
|
1085
|
-
return refuse(offered.reason === "not-open" ? "closed" : "item-capacity");
|
|
1086
|
-
}
|
|
1087
|
-
accepted += 1;
|
|
1088
|
-
pendingItems += 1;
|
|
1089
|
-
pendingBytes += bytes.byteLength;
|
|
1090
|
-
lastAcceptedSequence = sequence;
|
|
1091
|
-
return DiagnosticJournalSubmitResultSchema.parse({
|
|
1092
|
-
outcome: "accepted",
|
|
1093
|
-
epoch: config.epoch,
|
|
1094
|
-
sequence
|
|
1095
|
-
});
|
|
1096
|
-
},
|
|
1097
|
-
async flush(options = {}) {
|
|
1098
|
-
const throughSequence = lastAcceptedSequence;
|
|
1099
|
-
const outcome = await waitUntil(() => lastSettledSequence >= throughSequence, options);
|
|
1100
|
-
return DiagnosticJournalWaitResultSchema.parse({
|
|
1101
|
-
outcome,
|
|
1102
|
-
state,
|
|
1103
|
-
throughSequence,
|
|
1104
|
-
settledSequence: lastSettledSequence
|
|
1105
|
-
});
|
|
1106
|
-
},
|
|
1107
|
-
getStatus,
|
|
1108
|
-
async close(options = {}) {
|
|
1109
|
-
if (state === "open") {
|
|
1110
|
-
state = "draining";
|
|
1111
|
-
channel.close({ mode: "drain" });
|
|
1112
|
-
}
|
|
1113
|
-
const wait = await waitUntil(() => physicalClosed, options);
|
|
1114
|
-
return DiagnosticJournalCloseResultSchema.parse({
|
|
1115
|
-
outcome: wait === "settled" ? "closed" : wait,
|
|
1116
|
-
state,
|
|
1117
|
-
pendingItems
|
|
1118
|
-
});
|
|
1119
|
-
}
|
|
1120
|
-
};
|
|
1121
1012
|
}
|
|
1122
1013
|
|
|
1123
|
-
// src/application/diagnostic-journal.ts
|
|
1124
|
-
async function createDiagnosticJournal(config) {
|
|
1125
|
-
const limits = DiagnosticJournalLimitsSchema.parse(config.limits);
|
|
1126
|
-
const lock = DiagnosticJournalLockPolicySchema.parse(config.lock ?? "refuse");
|
|
1127
|
-
const storage = await createRotatingDiagnosticJournalStorage({
|
|
1128
|
-
path: config.path,
|
|
1129
|
-
maxFileBytes: limits.maxFileBytes,
|
|
1130
|
-
maxFiles: limits.maxFiles,
|
|
1131
|
-
mode: parseDiagnosticJournalMode(config.mode),
|
|
1132
|
-
lock,
|
|
1133
|
-
...config.machineIdentity !== undefined && { machineIdentity: config.machineIdentity }
|
|
1134
|
-
});
|
|
1135
|
-
return createDiagnosticJournalManager({
|
|
1136
|
-
eventSchema: config.eventSchema,
|
|
1137
|
-
epoch: randomUUID(),
|
|
1138
|
-
limits,
|
|
1139
|
-
lock: { policy: lock, reclaimedStale: storage.reclaimedStale },
|
|
1140
|
-
...config.onFailure && { onFailure: config.onFailure }
|
|
1141
|
-
}, storage);
|
|
1142
|
-
}
|
|
1143
1014
|
// src/application/events.ts
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
type: z4.literal("application-state"),
|
|
1015
|
+
var ApplicationLifecycleEventSchema = z7.object({
|
|
1016
|
+
type: z7.literal("application-state"),
|
|
1147
1017
|
applicationId: ApplicationIdSchema,
|
|
1148
|
-
epoch:
|
|
1149
|
-
revision:
|
|
1018
|
+
epoch: z7.string().uuid(),
|
|
1019
|
+
revision: z7.number().int().nonnegative(),
|
|
1150
1020
|
lifecycle: ApplicationLifecycleSchema,
|
|
1151
1021
|
health: ApplicationHealthSchema,
|
|
1152
|
-
ready:
|
|
1153
|
-
capturedAt:
|
|
1154
|
-
resources:
|
|
1022
|
+
ready: z7.boolean(),
|
|
1023
|
+
capturedAt: z7.string().datetime({ offset: true }),
|
|
1024
|
+
resources: z7.array(ManagedResourceSnapshotSchema).readonly()
|
|
1155
1025
|
}).strict().readonly();
|
|
1156
1026
|
function applicationLifecycleEvent(snapshot) {
|
|
1157
1027
|
return ApplicationLifecycleEventSchema.parse({
|
|
@@ -1182,13 +1052,13 @@ function createApplicationEventSink(config) {
|
|
|
1182
1052
|
};
|
|
1183
1053
|
}
|
|
1184
1054
|
// src/application/health.ts
|
|
1185
|
-
import { z as
|
|
1186
|
-
var ApplicationHealthHandlerOptionsSchema =
|
|
1187
|
-
kind:
|
|
1188
|
-
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)
|
|
1189
1059
|
});
|
|
1190
|
-
var ApplicationOperationalHandlersOptionsSchema =
|
|
1191
|
-
retryAfterSeconds:
|
|
1060
|
+
var ApplicationOperationalHandlersOptionsSchema = z8.object({
|
|
1061
|
+
retryAfterSeconds: z8.number().int().nonnegative().default(5)
|
|
1192
1062
|
});
|
|
1193
1063
|
function createApplicationHealthHandler(application, options) {
|
|
1194
1064
|
const parsed = ApplicationHealthHandlerOptionsSchema.parse(options);
|
|
@@ -1215,6 +1085,901 @@ function createApplicationOperationalHandlers(application, options = {}) {
|
|
|
1215
1085
|
})
|
|
1216
1086
|
};
|
|
1217
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
|
+
}
|
|
1218
1983
|
// src/application/keyspace.ts
|
|
1219
1984
|
function defineKeyspace(name, declaration) {
|
|
1220
1985
|
if (name.trim() === "") {
|
|
@@ -1238,7 +2003,7 @@ function keyspaceResource(declaration, config) {
|
|
|
1238
2003
|
id: config.id ?? `keyspace:${declaration.name}`,
|
|
1239
2004
|
...config.dependsOn && { dependsOn: config.dependsOn },
|
|
1240
2005
|
async start(_context) {
|
|
1241
|
-
await machine.
|
|
2006
|
+
await machine.open();
|
|
1242
2007
|
return { value: machine.keyspace };
|
|
1243
2008
|
},
|
|
1244
2009
|
stopAdmission: machine.stopAdmission,
|
|
@@ -1251,9 +2016,14 @@ function keyspaceMachine(declaration, config) {
|
|
|
1251
2016
|
const maxPending = config.maxPendingWrites ?? 1024;
|
|
1252
2017
|
const records = new Map;
|
|
1253
2018
|
let admitting = true;
|
|
2019
|
+
let backend;
|
|
2020
|
+
const currentBackend = () => {
|
|
2021
|
+
backend ??= typeof config.backend === "function" ? config.backend() : config.backend;
|
|
2022
|
+
return backend;
|
|
2023
|
+
};
|
|
1254
2024
|
let pending = 0;
|
|
1255
2025
|
let chain = Promise.resolve();
|
|
1256
|
-
function
|
|
2026
|
+
function run(work) {
|
|
1257
2027
|
if (!admitting) {
|
|
1258
2028
|
return Promise.reject(new Error(`[stitchkit] keyspace "${declaration.name}" is shutting down and is not accepting writes.`));
|
|
1259
2029
|
}
|
|
@@ -1280,8 +2050,8 @@ function keyspaceMachine(declaration, config) {
|
|
|
1280
2050
|
put(value) {
|
|
1281
2051
|
const parsed = declaration.schema.parse(value);
|
|
1282
2052
|
const key = declaration.key(parsed);
|
|
1283
|
-
return
|
|
1284
|
-
await
|
|
2053
|
+
return run(async () => {
|
|
2054
|
+
await currentBackend().put(key, parsed);
|
|
1285
2055
|
records.set(key, parsed);
|
|
1286
2056
|
config.onChanged?.({
|
|
1287
2057
|
keyspace: declaration.name,
|
|
@@ -1292,8 +2062,8 @@ function keyspaceMachine(declaration, config) {
|
|
|
1292
2062
|
});
|
|
1293
2063
|
},
|
|
1294
2064
|
delete(key) {
|
|
1295
|
-
return
|
|
1296
|
-
await
|
|
2065
|
+
return run(async () => {
|
|
2066
|
+
await currentBackend().delete(key);
|
|
1297
2067
|
records.delete(key);
|
|
1298
2068
|
config.onChanged?.({ keyspace: declaration.name, key, change: "delete" });
|
|
1299
2069
|
});
|
|
@@ -1307,11 +2077,19 @@ function keyspaceMachine(declaration, config) {
|
|
|
1307
2077
|
return {
|
|
1308
2078
|
keyspace,
|
|
1309
2079
|
async load() {
|
|
1310
|
-
for (const stored of await
|
|
2080
|
+
for (const stored of await currentBackend().load()) {
|
|
1311
2081
|
const value = declaration.schema.parse(stored);
|
|
1312
2082
|
records.set(declaration.key(value), value);
|
|
1313
2083
|
}
|
|
1314
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
|
+
},
|
|
1315
2093
|
stopAdmission() {
|
|
1316
2094
|
admitting = false;
|
|
1317
2095
|
},
|
|
@@ -1320,7 +2098,7 @@ function keyspaceMachine(declaration, config) {
|
|
|
1320
2098
|
},
|
|
1321
2099
|
async close() {
|
|
1322
2100
|
reportUnwritten();
|
|
1323
|
-
await
|
|
2101
|
+
await backend?.close?.();
|
|
1324
2102
|
},
|
|
1325
2103
|
force() {
|
|
1326
2104
|
reportUnwritten();
|
|
@@ -1370,6 +2148,308 @@ function sqliteKeyspaceBackend(declaration, config) {
|
|
|
1370
2148
|
}
|
|
1371
2149
|
};
|
|
1372
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
|
+
}
|
|
1373
2453
|
// src/application/server-resource.ts
|
|
1374
2454
|
function managedServerResource(config) {
|
|
1375
2455
|
let shutdownPromise;
|
|
@@ -1411,6 +2491,11 @@ function managedServerResource(config) {
|
|
|
1411
2491
|
...config.dependsOn && { dependsOn: config.dependsOn },
|
|
1412
2492
|
...config.required !== undefined && { required: config.required },
|
|
1413
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;
|
|
1414
2499
|
startAttempted = true;
|
|
1415
2500
|
started = await resolveServer(context);
|
|
1416
2501
|
return { value: started };
|
|
@@ -1429,109 +2514,6 @@ function managedServerResource(config) {
|
|
|
1429
2514
|
}
|
|
1430
2515
|
});
|
|
1431
2516
|
}
|
|
1432
|
-
// src/browser/resumable.ts
|
|
1433
|
-
import { z as z6 } from "zod";
|
|
1434
|
-
var PositiveSafeIntegerSchema3 = z6.number().int().positive().safe();
|
|
1435
|
-
var BackoffPolicySchema = z6.object({
|
|
1436
|
-
minDelayMs: PositiveSafeIntegerSchema3,
|
|
1437
|
-
maxDelayMs: PositiveSafeIntegerSchema3,
|
|
1438
|
-
jitter: z6.number().min(0).max(1)
|
|
1439
|
-
}).strict().readonly().refine((policy) => policy.maxDelayMs >= policy.minDelayMs, {
|
|
1440
|
-
message: "maxDelayMs must be at least minDelayMs"
|
|
1441
|
-
});
|
|
1442
|
-
function createBackoff(policy, random = Math.random) {
|
|
1443
|
-
const { minDelayMs, maxDelayMs, jitter } = BackoffPolicySchema.parse(policy);
|
|
1444
|
-
let attempt = 0;
|
|
1445
|
-
return {
|
|
1446
|
-
next() {
|
|
1447
|
-
const exponential = Math.min(maxDelayMs, minDelayMs * 2 ** attempt);
|
|
1448
|
-
attempt += 1;
|
|
1449
|
-
const spread = exponential * jitter * random();
|
|
1450
|
-
return Math.max(1, Math.round(exponential - spread));
|
|
1451
|
-
},
|
|
1452
|
-
reset() {
|
|
1453
|
-
attempt = 0;
|
|
1454
|
-
}
|
|
1455
|
-
};
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
|
-
// src/live/watch-contract.ts
|
|
1459
|
-
import { z as z8 } from "zod";
|
|
1460
|
-
|
|
1461
|
-
// src/browser/live-state.ts
|
|
1462
|
-
import { z as z7 } from "zod";
|
|
1463
|
-
var PositiveSafeIntegerSchema4 = z7.number().int().positive().safe();
|
|
1464
|
-
var LiveStatePhaseSchema = z7.enum([
|
|
1465
|
-
"idle",
|
|
1466
|
-
"opening",
|
|
1467
|
-
"live",
|
|
1468
|
-
"resync-required",
|
|
1469
|
-
"unavailable",
|
|
1470
|
-
"closed"
|
|
1471
|
-
]);
|
|
1472
|
-
var LiveStateStopReasonSchema = z7.enum([
|
|
1473
|
-
"gap",
|
|
1474
|
-
"buffer-overflow",
|
|
1475
|
-
"source-unavailable",
|
|
1476
|
-
"source-error",
|
|
1477
|
-
"controller-error",
|
|
1478
|
-
"controller-capacity"
|
|
1479
|
-
]);
|
|
1480
|
-
var LiveStateControllerStatusSchema = z7.object({
|
|
1481
|
-
phase: LiveStatePhaseSchema,
|
|
1482
|
-
generation: z7.number().int().nonnegative(),
|
|
1483
|
-
hasValue: z7.boolean(),
|
|
1484
|
-
bufferedEvents: z7.number().int().nonnegative(),
|
|
1485
|
-
bufferedBytes: z7.number().int().nonnegative(),
|
|
1486
|
-
receivedEvents: z7.number().int().nonnegative(),
|
|
1487
|
-
appliedEvents: z7.number().int().nonnegative(),
|
|
1488
|
-
duplicateEvents: z7.number().int().nonnegative(),
|
|
1489
|
-
gapEvents: z7.number().int().nonnegative(),
|
|
1490
|
-
refusedEvents: z7.number().int().nonnegative(),
|
|
1491
|
-
reason: LiveStateStopReasonSchema.optional()
|
|
1492
|
-
}).strict().readonly();
|
|
1493
|
-
|
|
1494
|
-
// src/live/watch-contract.ts
|
|
1495
|
-
var WatchKeySchema = z8.object({
|
|
1496
|
-
service: z8.string().min(1),
|
|
1497
|
-
action: z8.string().min(1),
|
|
1498
|
-
digest: z8.string().min(1)
|
|
1499
|
-
}).strict().readonly();
|
|
1500
|
-
var WatchOpenSchema = z8.object({ key: WatchKeySchema, args: z8.unknown() }).readonly();
|
|
1501
|
-
var WatchAcceptedSchema = z8.object({
|
|
1502
|
-
accepted: z8.boolean(),
|
|
1503
|
-
reason: z8.string().optional()
|
|
1504
|
-
}).strict().readonly();
|
|
1505
|
-
var WatchValueSchema = z8.object({
|
|
1506
|
-
key: WatchKeySchema,
|
|
1507
|
-
revision: z8.number().int().nonnegative(),
|
|
1508
|
-
value: z8.unknown()
|
|
1509
|
-
}).readonly();
|
|
1510
|
-
var WatchStateSchema = z8.object({
|
|
1511
|
-
key: WatchKeySchema,
|
|
1512
|
-
phase: LiveStatePhaseSchema,
|
|
1513
|
-
reason: LiveStateStopReasonSchema.optional(),
|
|
1514
|
-
code: z8.string().optional(),
|
|
1515
|
-
message: z8.string().optional()
|
|
1516
|
-
}).readonly();
|
|
1517
|
-
var WATCH_OPEN = "stitchkit.watch.open";
|
|
1518
|
-
var WATCH_CLOSE = "stitchkit.watch.close";
|
|
1519
|
-
var WATCH_VALUE = "stitchkit.watch.value";
|
|
1520
|
-
var WATCH_STATE = "stitchkit.watch.state";
|
|
1521
|
-
var watchContract = {
|
|
1522
|
-
serverToClient: {
|
|
1523
|
-
[WATCH_VALUE]: { args: z8.tuple([WatchValueSchema]) },
|
|
1524
|
-
[WATCH_STATE]: { args: z8.tuple([WatchStateSchema]) }
|
|
1525
|
-
},
|
|
1526
|
-
clientToServer: {
|
|
1527
|
-
[WATCH_OPEN]: { args: z8.tuple([WatchOpenSchema]), ack: WatchAcceptedSchema },
|
|
1528
|
-
[WATCH_CLOSE]: { args: z8.tuple([z8.object({ key: WatchKeySchema }).readonly()]) }
|
|
1529
|
-
}
|
|
1530
|
-
};
|
|
1531
|
-
function watchKeyString(key) {
|
|
1532
|
-
return `${key.service}/${key.action}/${key.digest}`;
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
2517
|
// src/application/watch-hub.ts
|
|
1536
2518
|
var DEFAULT_BACKOFF = { minDelayMs: 250, maxDelayMs: 30000, jitter: 0.2 };
|
|
1537
2519
|
function watchKey(operation, args) {
|
|
@@ -1755,7 +2737,7 @@ export {
|
|
|
1755
2737
|
defineKeyspace,
|
|
1756
2738
|
createWatchHub,
|
|
1757
2739
|
createManagedSchedule,
|
|
1758
|
-
|
|
2740
|
+
createDecisionPipeline,
|
|
1759
2741
|
createCreditWindow,
|
|
1760
2742
|
createBoundedChannel,
|
|
1761
2743
|
createBoundedAdmission,
|
|
@@ -1766,6 +2748,7 @@ export {
|
|
|
1766
2748
|
createApplication,
|
|
1767
2749
|
createActivityProjection,
|
|
1768
2750
|
applicationLifecycleEvent,
|
|
2751
|
+
PolicyDecisionSchema,
|
|
1769
2752
|
ManagedScheduleStatusSchema,
|
|
1770
2753
|
ManagedScheduleOverlapSchema,
|
|
1771
2754
|
ManagedScheduleErrorPolicySchema,
|
|
@@ -1782,6 +2765,8 @@ export {
|
|
|
1782
2765
|
DiagnosticJournalFrameSchema,
|
|
1783
2766
|
DiagnosticJournalFailurePhaseSchema,
|
|
1784
2767
|
DiagnosticJournalCloseResultSchema,
|
|
2768
|
+
DecisionUndecidedError,
|
|
2769
|
+
DecisionPolicyError,
|
|
1785
2770
|
CreditWindowSnapshotSchema,
|
|
1786
2771
|
BoundedRateBudgetSchema,
|
|
1787
2772
|
BoundedOperationWaitError,
|
|
@@ -1801,6 +2786,9 @@ export {
|
|
|
1801
2786
|
ApplicationShutdownResultSchema,
|
|
1802
2787
|
ApplicationShutdownOptionsSchema,
|
|
1803
2788
|
ApplicationShutdownBudgetSchema,
|
|
2789
|
+
ApplicationRestartResultSchema,
|
|
2790
|
+
ApplicationRestartOutcomeSchema,
|
|
2791
|
+
ApplicationRestartInputSchema,
|
|
1804
2792
|
ApplicationResourceShutdownSchema,
|
|
1805
2793
|
ApplicationOperationalHandlersOptionsSchema,
|
|
1806
2794
|
ApplicationLifecycleSchema,
|