@telorun/kernel 0.69.0 → 0.72.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/bigint-json.d.ts +45 -0
- package/dist/bigint-json.d.ts.map +1 -0
- package/dist/bigint-json.js +65 -0
- package/dist/bigint-json.js.map +1 -0
- package/dist/bigint-schema-view.d.ts +34 -0
- package/dist/bigint-schema-view.d.ts.map +1 -0
- package/dist/bigint-schema-view.js +85 -0
- package/dist/bigint-schema-view.js.map +1 -0
- package/dist/cel-handlers.d.ts.map +1 -1
- package/dist/cel-handlers.js +6 -8
- package/dist/cel-handlers.js.map +1 -1
- package/dist/controllers/module/import-controller.d.ts.map +1 -1
- package/dist/controllers/module/import-controller.js +15 -1
- package/dist/controllers/module/import-controller.js.map +1 -1
- package/dist/evaluation-context.d.ts.map +1 -1
- package/dist/evaluation-context.js +8 -1
- package/dist/evaluation-context.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/invocation-contract-binding.d.ts +0 -6
- package/dist/invocation-contract-binding.d.ts.map +1 -1
- package/dist/invocation-contract-binding.js +9 -64
- package/dist/invocation-contract-binding.js.map +1 -1
- package/dist/kernel.d.ts +4 -0
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +22 -3
- package/dist/kernel.js.map +1 -1
- package/dist/logging/encode-json.d.ts.map +1 -1
- package/dist/logging/encode-json.js +13 -9
- package/dist/logging/encode-json.js.map +1 -1
- package/dist/logging/encode-pretty.d.ts.map +1 -1
- package/dist/logging/encode-pretty.js +13 -4
- package/dist/logging/encode-pretty.js.map +1 -1
- package/dist/module-file-resolution.d.ts +22 -0
- package/dist/module-file-resolution.d.ts.map +1 -0
- package/dist/module-file-resolution.js +51 -0
- package/dist/module-file-resolution.js.map +1 -0
- package/dist/resolve-include-sentinels.d.ts +37 -0
- package/dist/resolve-include-sentinels.d.ts.map +1 -0
- package/dist/resolve-include-sentinels.js +175 -0
- package/dist/resolve-include-sentinels.js.map +1 -0
- package/dist/resource-context.d.ts +10 -1
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +42 -45
- package/dist/resource-context.js.map +1 -1
- package/dist/runtime-seam.d.ts.map +1 -1
- package/dist/runtime-seam.js +10 -1
- package/dist/runtime-seam.js.map +1 -1
- package/dist/schema-validator.d.ts.map +1 -1
- package/dist/schema-validator.js +23 -12
- package/dist/schema-validator.js.map +1 -1
- package/package.json +4 -4
- package/src/bigint-json.ts +69 -0
- package/src/bigint-schema-view.ts +76 -0
- package/src/cel-handlers.ts +6 -9
- package/src/controllers/module/import-controller.ts +14 -1
- package/src/evaluation-context.ts +8 -1
- package/src/index.ts +1 -0
- package/src/invocation-contract-binding.ts +9 -55
- package/src/kernel.ts +29 -0
- package/src/logging/encode-json.ts +14 -9
- package/src/logging/encode-pretty.ts +12 -3
- package/src/module-file-resolution.ts +64 -0
- package/src/resolve-include-sentinels.ts +219 -0
- package/src/resource-context.ts +46 -45
- package/src/runtime-seam.ts +11 -0
- package/src/schema-validator.ts +23 -12
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { readFile, stat } from "fs/promises";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
import { RuntimeError, type ResourceManifest } from "@telorun/sdk";
|
|
4
|
+
import {
|
|
5
|
+
INCLUDE_BYTES_ENGINE,
|
|
6
|
+
isIncludeSentinel,
|
|
7
|
+
isTaggedSentinel,
|
|
8
|
+
normalizeIncludePath,
|
|
9
|
+
type TaggedSentinel,
|
|
10
|
+
} from "@telorun/templating";
|
|
11
|
+
import { resolveModuleFileUri, type ModuleArtifactLookup } from "./module-file-resolution.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Ceiling on one embedded file.
|
|
15
|
+
*
|
|
16
|
+
* A resolved embed is an ordinary manifest value, retained for as long as the
|
|
17
|
+
* resource holding it, so there is no point at which a large one is released.
|
|
18
|
+
* Streaming a payload is a different primitive with a different lifetime, which
|
|
19
|
+
* is what the error points at.
|
|
20
|
+
*/
|
|
21
|
+
export const MAX_INCLUDE_BYTES = 32 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
/** Reads keyed by resolved URI. Two resources embedding the same font read it
|
|
24
|
+
* once — the values are retained by those resources anyway, so the cache adds
|
|
25
|
+
* deduplication rather than retention. */
|
|
26
|
+
export type IncludeCache = Map<string, Uint8Array>;
|
|
27
|
+
|
|
28
|
+
/** Manifest objects already walked. Keyed by identity and weakly held, so this
|
|
29
|
+
* is a gate on repeated work rather than a lifetime extension. */
|
|
30
|
+
const resolved = new WeakSet<object>();
|
|
31
|
+
|
|
32
|
+
async function readIncluded(
|
|
33
|
+
uri: string,
|
|
34
|
+
displayPath: string,
|
|
35
|
+
cache: IncludeCache,
|
|
36
|
+
): Promise<Uint8Array> {
|
|
37
|
+
const cached = cache.get(uri);
|
|
38
|
+
if (cached) return cached;
|
|
39
|
+
|
|
40
|
+
if (!uri.startsWith("file:")) {
|
|
41
|
+
throw new RuntimeError(
|
|
42
|
+
"ERR_INCLUDE_UNREADABLE",
|
|
43
|
+
`Cannot embed '${displayPath}': it resolved to '${uri}', which this runtime cannot read ` +
|
|
44
|
+
`as a file. An embedded file must ship inside the module's own artifact.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const filePath = fileURLToPath(uri);
|
|
48
|
+
|
|
49
|
+
// Size is checked before reading, so an oversized file is reported rather
|
|
50
|
+
// than loaded to discover it was too big.
|
|
51
|
+
let size: number;
|
|
52
|
+
try {
|
|
53
|
+
const info = await stat(filePath);
|
|
54
|
+
if (!info.isFile()) {
|
|
55
|
+
throw new RuntimeError(
|
|
56
|
+
"ERR_INCLUDE_UNREADABLE",
|
|
57
|
+
`Cannot embed '${displayPath}': '${filePath}' is not a file.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
size = info.size;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error instanceof RuntimeError) throw error;
|
|
63
|
+
throw new RuntimeError(
|
|
64
|
+
"ERR_INCLUDE_FILE_NOT_FOUND",
|
|
65
|
+
`Cannot embed '${displayPath}': no such file at '${filePath}'. The path is relative to ` +
|
|
66
|
+
`the module root — the directory holding telo.yaml — not to the file the tag was ` +
|
|
67
|
+
`written in.`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (size > MAX_INCLUDE_BYTES) {
|
|
71
|
+
// Megabytes, not raw bytes: the limit is a round number chosen for a human,
|
|
72
|
+
// and this message is aimed at one.
|
|
73
|
+
const mb = (n: number) => `${Math.round((n / (1024 * 1024)) * 10) / 10} MB`;
|
|
74
|
+
throw new RuntimeError(
|
|
75
|
+
"ERR_INCLUDE_FILE_TOO_LARGE",
|
|
76
|
+
`Cannot embed '${displayPath}': it is ${mb(size)}, over the ${mb(MAX_INCLUDE_BYTES)} ` +
|
|
77
|
+
`limit for a file embedded into a manifest value. Read it at runtime with Fs.File ` +
|
|
78
|
+
`instead, which does not retain it for the life of the resource.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const bytes = new Uint8Array(await readFile(filePath));
|
|
83
|
+
cache.set(uri, bytes);
|
|
84
|
+
return bytes;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function resolveSentinel(
|
|
88
|
+
sentinel: TaggedSentinel,
|
|
89
|
+
moduleSource: string,
|
|
90
|
+
lookup: ModuleArtifactLookup,
|
|
91
|
+
cache: IncludeCache,
|
|
92
|
+
): Promise<string | Uint8Array> {
|
|
93
|
+
// Re-checked here rather than trusted from `telo check`: the kernel does not
|
|
94
|
+
// require that check to have run, and confinement is the one property whose
|
|
95
|
+
// absence is a security question rather than a broken build.
|
|
96
|
+
const { path: relative, diagnostic } = normalizeIncludePath(sentinel.source);
|
|
97
|
+
if (!relative) {
|
|
98
|
+
throw new RuntimeError(
|
|
99
|
+
"ERR_INCLUDE_PATH_INVALID",
|
|
100
|
+
`Invalid \`!${sentinel.engine}\` path: ${diagnostic?.message ?? "not a module-relative path."}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const uri = await resolveModuleFileUri(relative, moduleSource, lookup);
|
|
105
|
+
const bytes = await readIncluded(uri, relative, cache);
|
|
106
|
+
if (sentinel.engine === INCLUDE_BYTES_ENGINE) return bytes;
|
|
107
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Replace every `!include-text` / `!include-bytes` sentinel in a resource's
|
|
112
|
+
* config with the file's contents, in place.
|
|
113
|
+
*
|
|
114
|
+
* Called at resource creation — the kernel's single instance-production site —
|
|
115
|
+
* and NOT during manifest load. The artifact spec gives `telo.yaml` a layer of
|
|
116
|
+
* its own precisely so that reading a manifest cannot pull the whole artifact;
|
|
117
|
+
* resolving at load would defeat that just as thoroughly, because loading an app
|
|
118
|
+
* loads every imported library's manifest and would fetch every library's assets
|
|
119
|
+
* layer whether or not anything used it. Resolving here bounds the cost to
|
|
120
|
+
* modules whose resources actually instantiate, and a `with:`-scoped resource
|
|
121
|
+
* pays only when its scope runs.
|
|
122
|
+
*
|
|
123
|
+
* It runs BEFORE the resource's schema validation, so a resolved value is what
|
|
124
|
+
* the schema sees: bytes reach an `x-telo-binary` slot as the `Uint8Array` that
|
|
125
|
+
* annotation demands, and an unresolved marker never reaches a controller.
|
|
126
|
+
*
|
|
127
|
+
* In place, like `resolveRefSentinels` — which also makes it idempotent, so a
|
|
128
|
+
* scoped resource created once per scope run reads its files once.
|
|
129
|
+
*/
|
|
130
|
+
export async function resolveIncludeSentinels(
|
|
131
|
+
resource: ResourceManifest,
|
|
132
|
+
moduleSource: string,
|
|
133
|
+
lookup: ModuleArtifactLookup,
|
|
134
|
+
cache: IncludeCache,
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
// A resource that defers across init passes reaches `create()` more than once,
|
|
137
|
+
// and a `with:`-scoped one is created per scope run — the walk is idempotent
|
|
138
|
+
// either way, so repeating it is pure cost on the init loop. One walk per
|
|
139
|
+
// manifest object is enough: resolution rewrites it in place.
|
|
140
|
+
if (resolved.has(resource)) return;
|
|
141
|
+
|
|
142
|
+
const pending: Array<Promise<void>> = [];
|
|
143
|
+
|
|
144
|
+
/** A nested resource DECLARATION — an inline `{ kind, … }`, whether it sits in
|
|
145
|
+
* a `with:` scope or in a step's `invoke:`. Its embeds belong to it, not to
|
|
146
|
+
* the resource that encloses it, and it reaches `create()` in its own right.
|
|
147
|
+
* Phase-5 injection draws the same line for `!ref`s inside a scope. */
|
|
148
|
+
const isNestedDeclaration = (value: unknown): boolean =>
|
|
149
|
+
value !== null &&
|
|
150
|
+
typeof value === "object" &&
|
|
151
|
+
!Array.isArray(value) &&
|
|
152
|
+
typeof (value as { kind?: unknown }).kind === "string";
|
|
153
|
+
|
|
154
|
+
const take = (item: unknown, assign: (resolved: string | Uint8Array) => void): void => {
|
|
155
|
+
if (isIncludeSentinel(item)) {
|
|
156
|
+
pending.push(resolveSentinel(item, moduleSource, lookup, cache).then(assign));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Another engine's sentinel is opaque; a nested declaration is resolved when
|
|
160
|
+
// that resource is created, which is what keeps a scoped resource's files
|
|
161
|
+
// unread until its scope actually runs.
|
|
162
|
+
if (!isTaggedSentinel(item) && !isNestedDeclaration(item)) walk(item);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const walk = (value: unknown): void => {
|
|
166
|
+
if (value === null || typeof value !== "object") return;
|
|
167
|
+
// A compiled CEL node is opaque and carries no sentinels of its own.
|
|
168
|
+
if ((value as { __compiled?: unknown }).__compiled) return;
|
|
169
|
+
// Only PLAIN containers are descended into. A template kind expands
|
|
170
|
+
// `${{ self.connection }}` to a LIVE ResourceInstance, whose object graph
|
|
171
|
+
// reaches back into contexts and the kernel and contains cycles — walking it
|
|
172
|
+
// overflows the stack, and nothing in it could be a manifest value anyway.
|
|
173
|
+
// Same rule `compileWalker` and `precompileDoc` follow.
|
|
174
|
+
if (!Array.isArray(value)) {
|
|
175
|
+
const proto = Object.getPrototypeOf(value);
|
|
176
|
+
if (proto !== Object.prototype && proto !== null) return;
|
|
177
|
+
}
|
|
178
|
+
if (Array.isArray(value)) {
|
|
179
|
+
for (let i = 0; i < value.length; i++) {
|
|
180
|
+
const index = i;
|
|
181
|
+
take(value[index], (resolved) => {
|
|
182
|
+
value[index] = resolved;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const obj = value as Record<string, unknown>;
|
|
188
|
+
for (const key of Object.keys(obj)) {
|
|
189
|
+
take(obj[key], (resolved) => {
|
|
190
|
+
obj[key] = resolved;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// The resource being created is itself a `{ kind, … }` declaration, so the walk
|
|
196
|
+
// starts INSIDE it rather than at it — only the ones nested below are deferred.
|
|
197
|
+
walk(resource as Record<string, unknown>);
|
|
198
|
+
// Reads run concurrently and every failure surfaces: one unreadable file must
|
|
199
|
+
// not be hidden by another failing first.
|
|
200
|
+
const settled = await Promise.allSettled(pending);
|
|
201
|
+
const failures = settled.flatMap((r) => (r.status === "rejected" ? [r.reason] : []));
|
|
202
|
+
resolved.add(resource);
|
|
203
|
+
if (failures.length === 0) return;
|
|
204
|
+
// The FIRST failure is rethrown, carrying its own code and its own cause.
|
|
205
|
+
// Wrapping several into one generic error made the reported code depend on how
|
|
206
|
+
// many files happened to fail — two missing files became ERR_INCLUDE_UNREADABLE
|
|
207
|
+
// rather than ERR_INCLUDE_FILE_NOT_FOUND — so a caller matching on the code was
|
|
208
|
+
// misled by an artefact of the batch, and the individual errors were flattened
|
|
209
|
+
// into a string nothing could branch on. The rest are attached as `causes`, and
|
|
210
|
+
// named in the message so none is hidden.
|
|
211
|
+
const [first, ...rest] = failures as Error[];
|
|
212
|
+
if (rest.length > 0 && first instanceof RuntimeError) {
|
|
213
|
+
(first as RuntimeError & { causes?: unknown[] }).causes = rest;
|
|
214
|
+
first.message += ` (${rest.length} more embed${rest.length === 1 ? "" : "s"} also failed: ${rest
|
|
215
|
+
.map((f) => f.message)
|
|
216
|
+
.join("; ")})`;
|
|
217
|
+
}
|
|
218
|
+
throw first;
|
|
219
|
+
}
|
package/src/resource-context.ts
CHANGED
|
@@ -33,7 +33,9 @@ import { isRefSentinel } from "@telorun/templating";
|
|
|
33
33
|
import { ZoneContext } from "./zone-context.js";
|
|
34
34
|
import * as path from "path";
|
|
35
35
|
import { pathToFileURL } from "url";
|
|
36
|
+
import { withBigIntsAsNumbers } from "./bigint-schema-view.js";
|
|
36
37
|
import type { ModuleArtifact } from "./bundle/module-artifact.js";
|
|
38
|
+
import { resolveModuleFileUri } from "./module-file-resolution.js";
|
|
37
39
|
import { hostEnv } from "./host-env.js";
|
|
38
40
|
import type { LoggingHost } from "./logging/logging-host.js";
|
|
39
41
|
import type { ScopeConfig } from "./logging/scope-config.js";
|
|
@@ -74,6 +76,12 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
74
76
|
* eagerly for every context would allocate per resource for nothing. */
|
|
75
77
|
#log: Logger | undefined;
|
|
76
78
|
|
|
79
|
+
/** The resolved kind. It cannot come from `metadata`, which is the resource's
|
|
80
|
+
* metadata BLOCK — `kind` is its sibling, not its member, so reading
|
|
81
|
+
* `metadata.kind` yielded `undefined` and every controller record went out
|
|
82
|
+
* with no resource identity at all (§7.3). */
|
|
83
|
+
#resolvedKind: string | undefined;
|
|
84
|
+
|
|
77
85
|
/**
|
|
78
86
|
* The resource's structured logger, stamped with its identity, module, and
|
|
79
87
|
* import-alias scope so a record identifies *which instance* emitted it — the
|
|
@@ -82,8 +90,8 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
82
90
|
*/
|
|
83
91
|
get log(): Logger {
|
|
84
92
|
if (!this.#log) {
|
|
85
|
-
const kind = this
|
|
86
|
-
const name = this.metadata?.
|
|
93
|
+
const kind = this.#resolvedKind;
|
|
94
|
+
const name = this.metadata?.name as string | undefined;
|
|
87
95
|
const resource =
|
|
88
96
|
kind && name
|
|
89
97
|
? { kind, name, id: `${this.ownerPrefix}${kind}.${name}` }
|
|
@@ -150,7 +158,17 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
150
158
|
* dispatch fails.
|
|
151
159
|
*/
|
|
152
160
|
private readonly owningContext: IEvaluationContext = moduleContext,
|
|
161
|
+
/**
|
|
162
|
+
* The resolved kind, known long before `create()` runs. Passed here rather
|
|
163
|
+
* than waited for at `bindResourceIdentity` so `ctx.log` carries resource
|
|
164
|
+
* identity from the moment the context exists: a controller that captures
|
|
165
|
+
* `ctx.log` in its constructor — the natural thing to do when the logger is
|
|
166
|
+
* handed to a helper — would otherwise hold an identity-less logger for the
|
|
167
|
+
* resource's whole life, and nothing would report that it had.
|
|
168
|
+
*/
|
|
169
|
+
resolvedKind?: string,
|
|
153
170
|
) {
|
|
171
|
+
this.#resolvedKind = resolvedKind;
|
|
154
172
|
// `ctx.env` is the sanctioned host-env channel for controllers — always the
|
|
155
173
|
// real environment (kernel passes its snapshot), never the locked Proxy.
|
|
156
174
|
this.env = env ?? hostEnv();
|
|
@@ -313,7 +331,11 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
313
331
|
additionalProperties: false,
|
|
314
332
|
},
|
|
315
333
|
);
|
|
316
|
-
|
|
334
|
+
// A BigInt-normalized view: AJV reads `integer` as `typeof == "number"`, so a
|
|
335
|
+
// CEL integer (int64) would be rejected at a slot it satisfies. This validator
|
|
336
|
+
// runs without `useDefaults` and already checks a derived value, so there is
|
|
337
|
+
// nothing to merge back. See `bigint-schema-view.ts`.
|
|
338
|
+
const isValid = validate(withBigIntsAsNumbers(stripCompiledValues(value)));
|
|
317
339
|
if (!isValid) {
|
|
318
340
|
throw new RuntimeError(
|
|
319
341
|
"ERR_INVALID_VALUE",
|
|
@@ -343,6 +365,10 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
343
365
|
manifest: Record<string, unknown>,
|
|
344
366
|
): void {
|
|
345
367
|
this.#self = handle;
|
|
368
|
+
// The kind is NOT restated here. It is set at construction — the single
|
|
369
|
+
// production site always has it — and `#log` is memoized on first access, so
|
|
370
|
+
// a late assignment could not reach a logger a controller already holds. A
|
|
371
|
+
// fallback here would read as a guarantee it cannot provide.
|
|
346
372
|
this.#zones = new ZoneContext({
|
|
347
373
|
resourceName: (this.metadata?.name as string) ?? "<unnamed>",
|
|
348
374
|
resolvedKind,
|
|
@@ -420,10 +446,16 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
420
446
|
// settlement is already observed here.
|
|
421
447
|
const tracked = this.owningContext
|
|
422
448
|
.runDetached(fn) // bare scope-detach primitive
|
|
423
|
-
.catch(
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
449
|
+
.catch((err: unknown) => {
|
|
450
|
+
// A detached task has no caller to throw to, so this record is the only
|
|
451
|
+
// report. It replaces the bus event rather than joining it: `eventName`
|
|
452
|
+
// IS the bridge to the event bus (§4), and a record already reaches every
|
|
453
|
+
// sink including the debug wire, so emitting both would ship two copies
|
|
454
|
+
// of one fact with two payload shapes to keep in step.
|
|
455
|
+
this.log.error("Detached task failed", undefined, {
|
|
456
|
+
error: err,
|
|
457
|
+
eventName: "background.task.error",
|
|
458
|
+
});
|
|
427
459
|
})
|
|
428
460
|
.finally(() => {
|
|
429
461
|
this.pendingDetached.delete(tracked);
|
|
@@ -448,10 +480,12 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
448
480
|
await Promise.race([Promise.allSettled([...this.pendingDetached]), timeout]);
|
|
449
481
|
if (timer) clearTimeout(timer);
|
|
450
482
|
if (this.pendingDetached.size > 0) {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
483
|
+
this.log.warn(
|
|
484
|
+
`Abandoned ${this.pendingDetached.size} background task(s) after waiting ` +
|
|
485
|
+
`${DETACHED_DRAIN_TIMEOUT_MS}ms for them to drain`,
|
|
486
|
+
{ "telo.detached.abandoned": this.pendingDetached.size },
|
|
487
|
+
{ eventName: "background.task.abandoned" },
|
|
488
|
+
);
|
|
455
489
|
}
|
|
456
490
|
}
|
|
457
491
|
|
|
@@ -761,40 +795,7 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
761
795
|
* rebased onto the module directory.
|
|
762
796
|
*/
|
|
763
797
|
async resolveModuleFile(relative: string): Promise<string> {
|
|
764
|
-
|
|
765
|
-
// resolved and must not be rebased onto the module directory.
|
|
766
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(relative)) return relative;
|
|
767
|
-
if (path.isAbsolute(relative)) return pathToFileURL(relative).href;
|
|
768
|
-
|
|
769
|
-
const source = this.moduleContext.source;
|
|
770
|
-
const artifact = this.kernel.getModuleArtifact(source);
|
|
771
|
-
if (artifact) {
|
|
772
|
-
// Both the `assets` layer and `common` — the sink rule puts a file the
|
|
773
|
-
// author did not claim via `assets:` into `common`, and a module that ships
|
|
774
|
-
// static files with no bundled controller has no other route to its payload.
|
|
775
|
-
// Fetching only assets would leave such a module resolving into an empty
|
|
776
|
-
// directory.
|
|
777
|
-
await artifact.materializeModuleFiles();
|
|
778
|
-
return new URL(relative, pathToFileURL(path.join(artifact.directory, "/")).href).href;
|
|
779
|
-
}
|
|
780
|
-
// No artifact means no payload to fetch. That is normal for a module already
|
|
781
|
-
// on disk (development) or one that ships no files — but for a module reached
|
|
782
|
-
// over a non-local scheme it means the artifact carries no layer index, i.e. it
|
|
783
|
-
// predates layers. Raise the actionable error here rather than leaving each
|
|
784
|
-
// caller to invent its own message from a URI it cannot open.
|
|
785
|
-
if (!source.startsWith("file://") && !path.isAbsolute(source)) {
|
|
786
|
-
throw new RuntimeError(
|
|
787
|
-
"ERR_MODULE_FILES_UNAVAILABLE",
|
|
788
|
-
`Cannot resolve '${relative}' against module '${source}': the module's artifact ` +
|
|
789
|
-
`carries no layer index, so its files cannot be located. It was published by an ` +
|
|
790
|
-
`older Telo that wrote a single-blob artifact — republish the module, or import it ` +
|
|
791
|
-
`from a local path during development.`,
|
|
792
|
-
);
|
|
793
|
-
}
|
|
794
|
-
// Local module: resolve against the manifest URL, the same rule `include:`
|
|
795
|
-
// and sibling imports follow.
|
|
796
|
-
const base = source.startsWith("file://") ? source : pathToFileURL(source).href;
|
|
797
|
-
return new URL(relative, base).href;
|
|
798
|
+
return resolveModuleFileUri(relative, this.moduleContext.source, this.kernel);
|
|
798
799
|
}
|
|
799
800
|
|
|
800
801
|
on(event: string, handler: (payload?: any) => void | Promise<void>): void {
|
package/src/runtime-seam.ts
CHANGED
|
@@ -2,8 +2,10 @@ import {
|
|
|
2
2
|
Loader,
|
|
3
3
|
StaticAnalyzer,
|
|
4
4
|
collectZoneModuleDocuments,
|
|
5
|
+
diagnosticFix,
|
|
5
6
|
flattenForAnalyzer,
|
|
6
7
|
type AnalysisDiagnostic,
|
|
8
|
+
type DiagnosticData,
|
|
7
9
|
type ManifestSource,
|
|
8
10
|
type ZoneModuleDocuments,
|
|
9
11
|
} from "@telorun/analyzer";
|
|
@@ -116,6 +118,12 @@ const SEVERITY_NAMES: Record<number, CheckDiagnosticSeverity> = {
|
|
|
116
118
|
* read by kernels that speak no LSP. An unlabelled severity is an error: the
|
|
117
119
|
* analyzer's own default, and the safe reading for a caller gating on it. */
|
|
118
120
|
function toCheckDiagnostic(diagnostic: AnalysisDiagnostic): CheckDiagnostic {
|
|
121
|
+
// The repair is read through the analyzer's accessor rather than by casting
|
|
122
|
+
// `data`, so the stamp's shape stays owned by one module. `resource` / `path`
|
|
123
|
+
// ride along because a repair replaces the value AT `path`; forwarding the
|
|
124
|
+
// fix without its anchor gives a consumer something it cannot apply.
|
|
125
|
+
const fix = diagnosticFix(diagnostic);
|
|
126
|
+
const stamp = diagnostic.data as DiagnosticData | undefined;
|
|
119
127
|
return {
|
|
120
128
|
code: String(diagnostic.code ?? ""),
|
|
121
129
|
message: diagnostic.message,
|
|
@@ -123,6 +131,9 @@ function toCheckDiagnostic(diagnostic: AnalysisDiagnostic): CheckDiagnostic {
|
|
|
123
131
|
source: diagnostic.source,
|
|
124
132
|
line: diagnostic.range?.start?.line,
|
|
125
133
|
column: diagnostic.range?.start?.character,
|
|
134
|
+
...(stamp?.resource ? { resource: `${stamp.resource.kind}/${stamp.resource.name}` } : {}),
|
|
135
|
+
...(stamp?.path ? { path: stamp.path } : {}),
|
|
136
|
+
...(fix ? { fix: { replacement: fix.replacement } } : {}),
|
|
126
137
|
};
|
|
127
138
|
}
|
|
128
139
|
|
package/src/schema-validator.ts
CHANGED
|
@@ -8,17 +8,15 @@ import * as fs from "node:fs";
|
|
|
8
8
|
import { createRequire } from "node:module";
|
|
9
9
|
import * as path from "node:path";
|
|
10
10
|
import { binaryKeyword, X_TELO_BINARY } from "@telorun/analyzer";
|
|
11
|
+
import { mergeFilledDefaults, withBigIntsAsNumbers } from "./bigint-schema-view.js";
|
|
11
12
|
import { formatAjvErrors } from "./manifest-schemas.js";
|
|
12
13
|
|
|
13
|
-
/** Render a value for an error message without ever throwing
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* so serializing the offending data threw from inside the message template and
|
|
17
|
-
* the thrown stringify error REPLACED the validation failure. The author was
|
|
18
|
-
* told "cannot serialize BigInt" instead of which field was wrong. */
|
|
14
|
+
/** Render a value for an error message without ever throwing — the offending
|
|
15
|
+
* data may be cyclic, and a throw here would REPLACE the validation failure
|
|
16
|
+
* with an unrelated error naming no field. */
|
|
19
17
|
function describeValue(data: unknown): string {
|
|
20
18
|
try {
|
|
21
|
-
return JSON.stringify(data
|
|
19
|
+
return JSON.stringify(data) ?? String(data);
|
|
22
20
|
} catch {
|
|
23
21
|
return String(data);
|
|
24
22
|
}
|
|
@@ -399,19 +397,32 @@ export class SchemaValidator {
|
|
|
399
397
|
const validate = this.compileAjvOrLoadCached(sanitized, hash, persist);
|
|
400
398
|
if (persist) this.persistedHashes.add(hash);
|
|
401
399
|
|
|
400
|
+
// AJV's type check is `typeof data == "number"`, so a CEL integer — a BigInt —
|
|
401
|
+
// is rejected at an `integer` slot no matter what the author writes. Check a
|
|
402
|
+
// normalized VIEW instead and merge the `useDefaults` fills back, so the value
|
|
403
|
+
// that reaches the controller keeps its 64-bit range. `withBigIntsAsNumbers`
|
|
404
|
+
// returns the same reference when there was nothing to normalize, which is what
|
|
405
|
+
// keeps the BigInt-free path byte-identical to a plain `validate(data)`.
|
|
406
|
+
const check = (data: any): boolean => {
|
|
407
|
+
const view = withBigIntsAsNumbers(data);
|
|
408
|
+
const ok = validate(view);
|
|
409
|
+
if (ok && view !== data) mergeFilledDefaults(data, view);
|
|
410
|
+
return ok;
|
|
411
|
+
};
|
|
412
|
+
|
|
402
413
|
const validator = {
|
|
403
414
|
validate: (data: any) => {
|
|
404
|
-
|
|
405
|
-
|
|
415
|
+
if (!check(data)) {
|
|
416
|
+
// Reports `data`, not the normalized view: the view renders a wide
|
|
417
|
+
// integer through a double, so the digits it prints for the offending
|
|
418
|
+
// value would not be the ones the author wrote.
|
|
406
419
|
throw new RuntimeError(
|
|
407
420
|
"ERR_RESOURCE_SCHEMA_VALIDATION_FAILED",
|
|
408
421
|
`Invalid value passed: ${describeValue(data)}. Error: ${formatAjvErrors(validate.errors)}`,
|
|
409
422
|
);
|
|
410
423
|
}
|
|
411
424
|
},
|
|
412
|
-
isValid: (data: any) =>
|
|
413
|
-
return validate(data);
|
|
414
|
-
},
|
|
425
|
+
isValid: (data: any) => check(data),
|
|
415
426
|
};
|
|
416
427
|
|
|
417
428
|
this.hashCache.set(hash, validator);
|