@kindgi/handler-runtime 0.0.0-bootstrap.0 → 0.1.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/LICENSE +201 -0
- package/README.md +218 -2
- package/dist/build-extensions.d.ts +75 -0
- package/dist/build-extensions.d.ts.map +1 -0
- package/dist/build-extensions.js +27 -0
- package/dist/build-extensions.js.map +1 -0
- package/dist/discovery.d.ts +19 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +81 -0
- package/dist/discovery.js.map +1 -0
- package/dist/entrypoint.d.ts +10 -0
- package/dist/entrypoint.d.ts.map +1 -0
- package/dist/entrypoint.js +24 -0
- package/dist/entrypoint.js.map +1 -0
- package/dist/handler-runner.d.ts +127 -0
- package/dist/handler-runner.d.ts.map +1 -0
- package/dist/handler-runner.js +318 -0
- package/dist/handler-runner.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/kindgi-index-main.d.ts +2 -0
- package/dist/kindgi-index-main.d.ts.map +1 -0
- package/dist/kindgi-index-main.js +15 -0
- package/dist/kindgi-index-main.js.map +1 -0
- package/dist/kindgi-index.d.ts +316 -0
- package/dist/kindgi-index.d.ts.map +1 -0
- package/dist/kindgi-index.js +1201 -0
- package/dist/kindgi-index.js.map +1 -0
- package/dist/pack-env.d.ts +66 -0
- package/dist/pack-env.d.ts.map +1 -0
- package/dist/pack-env.js +97 -0
- package/dist/pack-env.js.map +1 -0
- package/dist/pack-service/index.d.ts +7 -0
- package/dist/pack-service/index.d.ts.map +1 -0
- package/dist/pack-service/index.js +6 -0
- package/dist/pack-service/index.js.map +1 -0
- package/dist/pack-service/main.d.ts +47 -0
- package/dist/pack-service/main.d.ts.map +1 -0
- package/dist/pack-service/main.js +201 -0
- package/dist/pack-service/main.js.map +1 -0
- package/dist/pack-service/service.d.ts +61 -0
- package/dist/pack-service/service.d.ts.map +1 -0
- package/dist/pack-service/service.js +341 -0
- package/dist/pack-service/service.js.map +1 -0
- package/dist/pack-service/supervisor.d.ts +138 -0
- package/dist/pack-service/supervisor.d.ts.map +1 -0
- package/dist/pack-service/supervisor.js +424 -0
- package/dist/pack-service/supervisor.js.map +1 -0
- package/dist/protocol.d.ts +104 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +116 -0
- package/dist/protocol.js.map +1 -0
- package/package.json +87 -4
- package/src/build-extensions.ts +100 -0
- package/src/discovery.ts +89 -0
- package/src/entrypoint.ts +23 -0
- package/src/handler-runner.ts +500 -0
- package/src/index.ts +66 -0
- package/src/kindgi-index-main.ts +17 -0
- package/src/kindgi-index.ts +1605 -0
- package/src/pack-env.ts +148 -0
- package/src/pack-service/index.ts +17 -0
- package/src/pack-service/main.ts +246 -0
- package/src/pack-service/service.ts +478 -0
- package/src/pack-service/supervisor.ts +600 -0
- package/src/protocol.ts +214 -0
|
@@ -0,0 +1,1605 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `kindgi-index` — the pack indexer, which writes a pack's `index.json`.
|
|
6
|
+
* `kindgi dev` runs it over the dev bundles; a pack image's indexer
|
|
7
|
+
* stage runs it (process entry `kindgi-index-main`, bundled as
|
|
8
|
+
* `dist/kindgi-index.mjs`) over the image's bundles, and `kindgi build`
|
|
9
|
+
* runs the same bundle locally, so the two indexes compare.
|
|
10
|
+
*
|
|
11
|
+
* The indexer:
|
|
12
|
+
*
|
|
13
|
+
* 1. Loads `kindgi.config.ts` (or `.js` / `.mjs`) at the pack root
|
|
14
|
+
* to read `pack.id` / `pack.version` + optional `discovery` glob
|
|
15
|
+
* overrides.
|
|
16
|
+
* 2. Discovers files under the four default folders — `tools/`,
|
|
17
|
+
* `guardrails/`, `agents/`, `flows/` — using the config's
|
|
18
|
+
* discovery patterns; or, indexing a build, takes the files a
|
|
19
|
+
* bundle map lists (`bundleMap`), classified by the same patterns.
|
|
20
|
+
* 3. Dynamically imports each file (from its bundle, given a map).
|
|
21
|
+
* Reads the `default` export
|
|
22
|
+
* (accepting either the raw primitive shape or a `Result`-wrapped
|
|
23
|
+
* envelope from `defineTool` / `defineGuardrail` / etc.).
|
|
24
|
+
* 4. Kind-maps the discovered file by its default folder (primary) or
|
|
25
|
+
* by structural shape of the default export (fallback for
|
|
26
|
+
* custom-pattern files).
|
|
27
|
+
* 5. For every Zod-typed schema (`tool.inputZod`, `tool.outputZod`,
|
|
28
|
+
* `check.configZod`) calls `@kindgi/schema.toJSONSchemaSync` to
|
|
29
|
+
* derive the JSON Schema wire form. When JSON Schema is authored
|
|
30
|
+
* directly, it is emitted verbatim.
|
|
31
|
+
* 6. Assembles a `v: 1` `index.json` envelope and writes it
|
|
32
|
+
* atomically (write-to-tmp, then rename) to the configured output
|
|
33
|
+
* path.
|
|
34
|
+
*
|
|
35
|
+
* ## Fail loud at every boundary
|
|
36
|
+
*
|
|
37
|
+
* Missing config, missing default export, kind mismatch, Zod conversion
|
|
38
|
+
* failure, filesystem write error — each is a distinct typed
|
|
39
|
+
* `IndexerError` variant with the file path attached. No silent skips.
|
|
40
|
+
*
|
|
41
|
+
* ## Determinism
|
|
42
|
+
*
|
|
43
|
+
* The integrity gate (locally verify the server's build) diffs an indexer output computed on the developer's
|
|
44
|
+
* laptop against the server's output. Byte-identical inputs must
|
|
45
|
+
* produce byte-identical outputs. All list fields are sorted
|
|
46
|
+
* lexicographically by id; JSON serialization uses compact stable
|
|
47
|
+
* output; `publishedAt` is accepted as an explicit `opts.publishedAt`
|
|
48
|
+
* for reproducible builds (the pack build passes it) and defaults to
|
|
49
|
+
* `new Date()` only for local dev (documented in the README).
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import * as fs from 'node:fs/promises';
|
|
53
|
+
import * as path from 'node:path';
|
|
54
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
55
|
+
|
|
56
|
+
import { type FlowOutputSpec, loadFlow } from '@kindgi/flow';
|
|
57
|
+
import { isZodSchema, loadZodConverterSync, toJSONSchemaSync } from '@kindgi/schema';
|
|
58
|
+
import type { AnySchema, ZodConverter, ZodLikeSchema } from '@kindgi/schema';
|
|
59
|
+
import type { Result } from '@kindgi/types';
|
|
60
|
+
import { parse as parseToml } from 'smol-toml';
|
|
61
|
+
|
|
62
|
+
import { TEST_FILE_REGEX, globStaticPrefix, globToRegex } from './discovery.js';
|
|
63
|
+
import { type PackEnvConfig, type PackEnvDeclaration, resolvePackEnv } from './pack-env.js';
|
|
64
|
+
|
|
65
|
+
// -----------------------------------------------------------------------
|
|
66
|
+
// Public envelope + shape types
|
|
67
|
+
// -----------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Envelope version emitted on `index.json`. Bump on breaking change;
|
|
71
|
+
* additive fields on an existing shape don't bump. Consumers (the
|
|
72
|
+
* controller) MUST refuse an envelope version they
|
|
73
|
+
* don't recognize.
|
|
74
|
+
*/
|
|
75
|
+
export const INDEX_ENVELOPE_VERSION = 1 as const;
|
|
76
|
+
export type IndexEnvelopeVersion = typeof INDEX_ENVELOPE_VERSION;
|
|
77
|
+
|
|
78
|
+
export type PrimitiveKind = 'tool' | 'guardrail' | 'agent' | 'flow';
|
|
79
|
+
|
|
80
|
+
/** Discovery glob patterns keyed by primitive kind. */
|
|
81
|
+
export interface DiscoveryConfig {
|
|
82
|
+
readonly tools?: string;
|
|
83
|
+
readonly guardrails?: string;
|
|
84
|
+
readonly agents?: string;
|
|
85
|
+
readonly flows?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The language a pack's code (tool handlers, guardrail checks) is written
|
|
90
|
+
* in — which indexer reads it and which pack service runs it. Agents and
|
|
91
|
+
* flows are data in either.
|
|
92
|
+
*/
|
|
93
|
+
export type PackLanguage = 'node' | 'python';
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The subset of `kindgi.config.ts` this indexer consumes. Other
|
|
97
|
+
* top-level fields (`environments`, `bundle`, etc.) are read by the
|
|
98
|
+
* deploy pipeline elsewhere and are opaque to the indexer.
|
|
99
|
+
*/
|
|
100
|
+
export interface KindgiConfig {
|
|
101
|
+
readonly pack: {
|
|
102
|
+
readonly id: string;
|
|
103
|
+
readonly version: string;
|
|
104
|
+
readonly description?: string;
|
|
105
|
+
};
|
|
106
|
+
readonly discovery?: DiscoveryConfig;
|
|
107
|
+
/**
|
|
108
|
+
* The pack's code language. Absent in `kindgi.config.*`: `node`. A
|
|
109
|
+
* `[tool.kindgi]` table in `pyproject.toml` is a Python pack unless it
|
|
110
|
+
* says otherwise.
|
|
111
|
+
*/
|
|
112
|
+
readonly language?: PackLanguage;
|
|
113
|
+
/**
|
|
114
|
+
* The process environment the pack's code reads: `required` names must
|
|
115
|
+
* be set for the pack service to be ready; `optional` ones are injected
|
|
116
|
+
* when a deployment has them. Carried in `index.json` (see
|
|
117
|
+
* `pack-env.ts`).
|
|
118
|
+
*/
|
|
119
|
+
readonly env?: PackEnvConfig;
|
|
120
|
+
readonly [k: string]: unknown;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The language of a loaded config's pack code. */
|
|
124
|
+
export function packLanguage(config: KindgiConfig): PackLanguage {
|
|
125
|
+
return config.language ?? 'node';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Default discovery patterns. Extensions
|
|
130
|
+
* `.ts` / `.js` / `.mjs` are all accepted — a pack build may transpile
|
|
131
|
+
* `.ts` to `.js` before the indexer runs inside the Dockerfile's indexer
|
|
132
|
+
* stage, but the indexer accepts either shape so local development (no
|
|
133
|
+
* bundle) and CI-built images (post-bundle) share the same code path.
|
|
134
|
+
*/
|
|
135
|
+
export const DEFAULT_DISCOVERY: Required<DiscoveryConfig> = {
|
|
136
|
+
tools: 'tools/**/*.{ts,js,mjs}',
|
|
137
|
+
guardrails: 'guardrails/**/*.{ts,js,mjs}',
|
|
138
|
+
agents: 'agents/**/*.{ts,js,mjs}',
|
|
139
|
+
flows: 'flows/**/*.{ts,js,mjs}',
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Default discovery patterns of a Python pack (`python -m kindgi.pack index`
|
|
144
|
+
* reads the same keys from `[tool.kindgi.discovery]`).
|
|
145
|
+
*/
|
|
146
|
+
export const DEFAULT_PYTHON_DISCOVERY: Required<DiscoveryConfig> = {
|
|
147
|
+
tools: 'tools/**/*.py',
|
|
148
|
+
guardrails: 'guardrails/**/*.py',
|
|
149
|
+
agents: 'agents/**/*.py',
|
|
150
|
+
flows: 'flows/**/*.py',
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** A config's discovery patterns with the language's defaults filled in. */
|
|
154
|
+
export function resolveDiscovery(
|
|
155
|
+
discovery: DiscoveryConfig | undefined,
|
|
156
|
+
language: PackLanguage = 'node',
|
|
157
|
+
): Required<DiscoveryConfig> {
|
|
158
|
+
const defaults = language === 'python' ? DEFAULT_PYTHON_DISCOVERY : DEFAULT_DISCOVERY;
|
|
159
|
+
return { ...defaults, ...(discovery ?? {}) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// -----------------------------------------------------------------------
|
|
163
|
+
// Manifest entry shapes emitted in index.json
|
|
164
|
+
// -----------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
export interface IndexedTool {
|
|
167
|
+
readonly id: string;
|
|
168
|
+
readonly description?: string;
|
|
169
|
+
readonly version?: string;
|
|
170
|
+
readonly input: Readonly<Record<string, unknown>>;
|
|
171
|
+
readonly output: Readonly<Record<string, unknown>>;
|
|
172
|
+
readonly effects?: readonly Readonly<Record<string, unknown>>[];
|
|
173
|
+
readonly needs?: readonly Readonly<Record<string, unknown>>[];
|
|
174
|
+
readonly needsSpec?: Readonly<Record<string, unknown>>;
|
|
175
|
+
readonly sandbox?: string;
|
|
176
|
+
readonly limits?: Readonly<Record<string, unknown>>;
|
|
177
|
+
readonly network?: Readonly<Record<string, unknown>>;
|
|
178
|
+
/**
|
|
179
|
+
* A declarative tool's spec (`defineTool({ spec })`, e.g. `kind:
|
|
180
|
+
* 'http'`). The runtime runs a tool that has one itself — and resolves
|
|
181
|
+
* its `secretRef`s — rather than calling the pack service.
|
|
182
|
+
*/
|
|
183
|
+
readonly spec?: Readonly<Record<string, unknown>>;
|
|
184
|
+
/**
|
|
185
|
+
* The tool's declared `mutating`. `false` makes it read-only: it runs
|
|
186
|
+
* in a dry run, and per-tool approval gates don't ask before it by
|
|
187
|
+
* default. Absent means it may change something.
|
|
188
|
+
*/
|
|
189
|
+
readonly mutating?: boolean;
|
|
190
|
+
readonly modulePath: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface IndexedGuardrail {
|
|
194
|
+
readonly id: string;
|
|
195
|
+
readonly name?: string;
|
|
196
|
+
readonly kind: string;
|
|
197
|
+
readonly action: Readonly<Record<string, unknown>>;
|
|
198
|
+
readonly severity?: string;
|
|
199
|
+
readonly scope?: Readonly<Record<string, unknown>>;
|
|
200
|
+
readonly checkModulePath: string;
|
|
201
|
+
readonly checkId?: string;
|
|
202
|
+
readonly configSchema?: Readonly<Record<string, unknown>>;
|
|
203
|
+
/** The guardrail's `config`: what its check is configured with. */
|
|
204
|
+
readonly config?: Readonly<Record<string, unknown>>;
|
|
205
|
+
readonly sandbox?: string;
|
|
206
|
+
readonly limits?: Readonly<Record<string, unknown>>;
|
|
207
|
+
readonly network?: Readonly<Record<string, unknown>>;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface IndexedAgent {
|
|
211
|
+
readonly id: string;
|
|
212
|
+
readonly version: string;
|
|
213
|
+
readonly name: string;
|
|
214
|
+
readonly instructions: string;
|
|
215
|
+
readonly capabilities: readonly unknown[];
|
|
216
|
+
/**
|
|
217
|
+
* Typed tool references — `{ id, version }` where `version` is a
|
|
218
|
+
* semver range. Structurally compatible with `@kindgi/agents`
|
|
219
|
+
* `ToolRef`.
|
|
220
|
+
*/
|
|
221
|
+
readonly tools: readonly { readonly id: string; readonly version: string }[];
|
|
222
|
+
readonly retrieval?: readonly unknown[];
|
|
223
|
+
readonly guardrails?: readonly string[];
|
|
224
|
+
readonly budget?: Readonly<Record<string, unknown>>;
|
|
225
|
+
readonly parameters?: readonly unknown[];
|
|
226
|
+
readonly preferredProvider?: string;
|
|
227
|
+
readonly preferredModel?: string;
|
|
228
|
+
readonly description?: string;
|
|
229
|
+
readonly tags?: readonly string[];
|
|
230
|
+
readonly conversationPolicy?: Readonly<Record<string, unknown>>;
|
|
231
|
+
/** The agent's typed output (`{ schema, name?, maxRepairs? }`), JSON Schema already. */
|
|
232
|
+
readonly output?: Readonly<Record<string, unknown>>;
|
|
233
|
+
/** How the agent's turns retry failed tool calls (`{ maxRetries?, retryOn? }`). */
|
|
234
|
+
readonly toolErrors?: Readonly<Record<string, unknown>>;
|
|
235
|
+
readonly modulePath: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Envelope version for the flow payload embedded inside `index.json`.
|
|
240
|
+
* Distinct from the top-level `v` envelope so the flow substrate can
|
|
241
|
+
* evolve its own shape independently — matches the `packages/flow`
|
|
242
|
+
* schema-version convention (currently 1.8.0, which added input and
|
|
243
|
+
* output mappings). Additive fields don't bump this.
|
|
244
|
+
*/
|
|
245
|
+
export const KERNEL_PAYLOAD_VERSION = 1 as const;
|
|
246
|
+
|
|
247
|
+
export interface IndexedFlow {
|
|
248
|
+
readonly id: string;
|
|
249
|
+
readonly version: string;
|
|
250
|
+
readonly name?: string;
|
|
251
|
+
readonly description?: string;
|
|
252
|
+
readonly nodes: readonly unknown[];
|
|
253
|
+
readonly edges: readonly unknown[];
|
|
254
|
+
readonly maxParallelism?: number;
|
|
255
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
256
|
+
/** The flow's declared output (schema-version 1.8.0+). */
|
|
257
|
+
readonly output?: FlowOutputSpec;
|
|
258
|
+
readonly kernelPayloadVersion: number;
|
|
259
|
+
readonly modulePath: string;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface Index {
|
|
263
|
+
readonly v: IndexEnvelopeVersion;
|
|
264
|
+
readonly packId: string;
|
|
265
|
+
readonly packVersion: string;
|
|
266
|
+
readonly artifactVersion: string;
|
|
267
|
+
readonly publishedAt: string;
|
|
268
|
+
readonly tools: readonly IndexedTool[];
|
|
269
|
+
readonly guardrails: readonly IndexedGuardrail[];
|
|
270
|
+
readonly agents: readonly IndexedAgent[];
|
|
271
|
+
readonly flows: readonly IndexedFlow[];
|
|
272
|
+
/** The pack's declared process environment (schema-version 1.4.0+); absent when it declares none. */
|
|
273
|
+
readonly env?: PackEnvDeclaration;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// -----------------------------------------------------------------------
|
|
277
|
+
// Error surface
|
|
278
|
+
// -----------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
export type IndexerErrorCode =
|
|
281
|
+
| 'config-not-found'
|
|
282
|
+
| 'config-parse-failed'
|
|
283
|
+
| 'config-invalid'
|
|
284
|
+
| 'discovery-empty'
|
|
285
|
+
| 'file-import-failed'
|
|
286
|
+
| 'no-default-export'
|
|
287
|
+
| 'kind-mismatch'
|
|
288
|
+
| 'ambiguous-kind'
|
|
289
|
+
| 'zod-conversion-failed'
|
|
290
|
+
| 'manifest-validation-failed'
|
|
291
|
+
| 'output-write-failed'
|
|
292
|
+
| 'language-mismatch';
|
|
293
|
+
|
|
294
|
+
export interface IndexerError {
|
|
295
|
+
readonly code: IndexerErrorCode;
|
|
296
|
+
readonly message: string;
|
|
297
|
+
readonly filePath?: string;
|
|
298
|
+
readonly field?: string;
|
|
299
|
+
readonly cause?: unknown;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// -----------------------------------------------------------------------
|
|
303
|
+
// Report + options
|
|
304
|
+
// -----------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
export interface IndexerReport {
|
|
307
|
+
readonly packId: string;
|
|
308
|
+
readonly packVersion: string;
|
|
309
|
+
readonly artifactVersion: string;
|
|
310
|
+
readonly publishedAt: string;
|
|
311
|
+
readonly counts: {
|
|
312
|
+
readonly tools: number;
|
|
313
|
+
readonly guardrails: number;
|
|
314
|
+
readonly agents: number;
|
|
315
|
+
readonly flows: number;
|
|
316
|
+
};
|
|
317
|
+
readonly outputPath: string;
|
|
318
|
+
/**
|
|
319
|
+
* Per-file errors accumulated during indexing — one bad file no
|
|
320
|
+
* longer aborts the whole pass. Registration continues on healthy
|
|
321
|
+
* primitives; the caller surfaces these to the user (dev watch,
|
|
322
|
+
* build log). Empty on a fully-clean pass.
|
|
323
|
+
*/
|
|
324
|
+
readonly fileErrors: readonly IndexerError[];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export interface RunIndexerOptions {
|
|
328
|
+
/** Root of the pack repo — the folder containing `kindgi.config.ts`. */
|
|
329
|
+
readonly packDir: string;
|
|
330
|
+
/**
|
|
331
|
+
* Explicit config file path. When omitted, the indexer tries
|
|
332
|
+
* `kindgi.config.ts`, `.mjs`, `.js`, and `.cjs` in that order at
|
|
333
|
+
* the pack root.
|
|
334
|
+
*/
|
|
335
|
+
readonly configPath?: string;
|
|
336
|
+
/**
|
|
337
|
+
* Explicit output path. When omitted, defaults to
|
|
338
|
+
* `<packDir>/index.json`.
|
|
339
|
+
*/
|
|
340
|
+
readonly outputPath?: string;
|
|
341
|
+
/**
|
|
342
|
+
* Explicit artifact version. When omitted, the indexer computes a
|
|
343
|
+
* `YYYYMMDD.N` shape locally — a pack build supplies an explicit
|
|
344
|
+
* value at Dockerfile invocation time so the image build is
|
|
345
|
+
* reproducible.
|
|
346
|
+
*/
|
|
347
|
+
readonly artifactVersion?: string;
|
|
348
|
+
/**
|
|
349
|
+
* Explicit publishedAt timestamp. Determinism gate — a pack build
|
|
350
|
+
* passes this as a build arg (SOURCE_DATE_EPOCH-shaped) so byte-identical
|
|
351
|
+
* source produces byte-identical index.json. Default: `new
|
|
352
|
+
* Date().toISOString()` — noted as non-deterministic in the README.
|
|
353
|
+
*/
|
|
354
|
+
readonly publishedAt?: string;
|
|
355
|
+
/**
|
|
356
|
+
* Index a build: each primitive's source path (relative to `packDir`,
|
|
357
|
+
* as the index records it) mapped to its bundle (relative to
|
|
358
|
+
* `moduleRoot`). Given, the map is the file list — the source tree
|
|
359
|
+
* needn't be there, as in a pack image — each key classified by the
|
|
360
|
+
* discovery patterns as `discoverFiles` classifies what it finds; each
|
|
361
|
+
* file is imported from its bundle, and the index records the source
|
|
362
|
+
* path.
|
|
363
|
+
*/
|
|
364
|
+
readonly bundleMap?: Readonly<Record<string, string>>;
|
|
365
|
+
/** Where `bundleMap`'s bundle paths are relative to. Default `packDir`. */
|
|
366
|
+
readonly moduleRoot?: string;
|
|
367
|
+
/**
|
|
368
|
+
* Test hook — override the default dynamic `import()`. Used by
|
|
369
|
+
* indexer tests to hand in in-memory module maps without a
|
|
370
|
+
* filesystem round-trip.
|
|
371
|
+
*/
|
|
372
|
+
readonly importModule?: (fileUrl: string) => Promise<unknown>;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// -----------------------------------------------------------------------
|
|
376
|
+
// Entry point
|
|
377
|
+
// -----------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Run the indexer stage. Never throws — every failure is captured as a
|
|
381
|
+
* typed `IndexerError`.
|
|
382
|
+
*/
|
|
383
|
+
export async function runIndexer(
|
|
384
|
+
opts: RunIndexerOptions,
|
|
385
|
+
): Promise<Result<IndexerReport, IndexerError>> {
|
|
386
|
+
const packDir = path.resolve(opts.packDir);
|
|
387
|
+
const importModule = opts.importModule ?? defaultImportModule;
|
|
388
|
+
|
|
389
|
+
const configResult = await loadConfig(packDir, opts.configPath, importModule);
|
|
390
|
+
if (configResult.kind === 'err') return configResult;
|
|
391
|
+
const config = configResult.value;
|
|
392
|
+
if (packLanguage(config) !== 'node') {
|
|
393
|
+
return {
|
|
394
|
+
kind: 'err',
|
|
395
|
+
error: {
|
|
396
|
+
code: 'language-mismatch',
|
|
397
|
+
message: `${packDir} is a ${packLanguage(config)} pack; index it with its own indexer (python -m kindgi.pack index)`,
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
const env = resolvePackEnv(config.env);
|
|
402
|
+
if (env.kind === 'err') {
|
|
403
|
+
return {
|
|
404
|
+
kind: 'err',
|
|
405
|
+
error: { code: 'config-invalid', field: 'env', message: `kindgi.config: ${env.message}` },
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const discovery = resolveDiscovery(config.discovery);
|
|
410
|
+
|
|
411
|
+
const bundleMap = opts.bundleMap;
|
|
412
|
+
const moduleRoot = path.resolve(opts.moduleRoot ?? packDir);
|
|
413
|
+
const discovered =
|
|
414
|
+
bundleMap !== undefined
|
|
415
|
+
? discoverInBundleMap(bundleMap, discovery)
|
|
416
|
+
: await discoverFiles(packDir, discovery);
|
|
417
|
+
if (discovered.length === 0) {
|
|
418
|
+
return {
|
|
419
|
+
kind: 'err',
|
|
420
|
+
error: {
|
|
421
|
+
code: 'discovery-empty',
|
|
422
|
+
message:
|
|
423
|
+
bundleMap !== undefined
|
|
424
|
+
? 'The bundle map lists no file the discovery patterns match. Check kindgi.config.ts discovery patterns.'
|
|
425
|
+
: `Indexer discovered zero files under ${packDir}. Check kindgi.config.ts discovery patterns.`,
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const zodConverter = loadZodConverterSync();
|
|
431
|
+
|
|
432
|
+
const tools: IndexedTool[] = [];
|
|
433
|
+
const guardrails: IndexedGuardrail[] = [];
|
|
434
|
+
const agents: IndexedAgent[] = [];
|
|
435
|
+
const flows: IndexedFlow[] = [];
|
|
436
|
+
const fileErrors: IndexerError[] = [];
|
|
437
|
+
|
|
438
|
+
for (const { relPath, expectedKind } of discovered) {
|
|
439
|
+
const bundle = bundleMap?.[relPath];
|
|
440
|
+
const absPath =
|
|
441
|
+
bundle !== undefined ? path.resolve(moduleRoot, bundle) : path.join(packDir, relPath);
|
|
442
|
+
const fileUrl = pathToFileURL(absPath).href;
|
|
443
|
+
|
|
444
|
+
let module_: unknown;
|
|
445
|
+
try {
|
|
446
|
+
module_ = await importModule(fileUrl);
|
|
447
|
+
} catch (cause) {
|
|
448
|
+
fileErrors.push({
|
|
449
|
+
code: 'file-import-failed',
|
|
450
|
+
message: `Failed to import ${relPath}: ${stringifyError(cause)}`,
|
|
451
|
+
filePath: relPath,
|
|
452
|
+
cause: serializeCause(cause),
|
|
453
|
+
});
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const defExport = extractDefaultExport(module_);
|
|
458
|
+
if (defExport === undefined) {
|
|
459
|
+
fileErrors.push({
|
|
460
|
+
code: 'no-default-export',
|
|
461
|
+
message: `File ${relPath} has no default export`,
|
|
462
|
+
filePath: relPath,
|
|
463
|
+
});
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const errResult = detectResultError(defExport);
|
|
468
|
+
if (errResult !== undefined) {
|
|
469
|
+
fileErrors.push({
|
|
470
|
+
code: 'manifest-validation-failed',
|
|
471
|
+
message: `File ${relPath} default export is a Result-wrapped error: ${resultErrorMessage(errResult)}`,
|
|
472
|
+
filePath: relPath,
|
|
473
|
+
cause: errResult,
|
|
474
|
+
});
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const unwrapped = unwrapResult(defExport);
|
|
479
|
+
const detected = detectKind(unwrapped);
|
|
480
|
+
if (detected === undefined) {
|
|
481
|
+
fileErrors.push({
|
|
482
|
+
code: 'ambiguous-kind',
|
|
483
|
+
message: `File ${relPath} default export does not match any primitive shape (tool / guardrail / agent / flow)`,
|
|
484
|
+
filePath: relPath,
|
|
485
|
+
});
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (expectedKind !== undefined && detected !== expectedKind) {
|
|
489
|
+
fileErrors.push({
|
|
490
|
+
code: 'kind-mismatch',
|
|
491
|
+
message: `File ${relPath} is under the default folder for kind '${expectedKind}' but default-exports a '${detected}'`,
|
|
492
|
+
filePath: relPath,
|
|
493
|
+
});
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
switch (detected) {
|
|
498
|
+
case 'tool': {
|
|
499
|
+
const built = buildTool(unwrapped, relPath, zodConverter);
|
|
500
|
+
if (built.kind === 'err') {
|
|
501
|
+
fileErrors.push(built.error);
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
tools.push(built.value);
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
case 'guardrail': {
|
|
508
|
+
const built = buildGuardrail(unwrapped, relPath, zodConverter);
|
|
509
|
+
if (built.kind === 'err') {
|
|
510
|
+
fileErrors.push(built.error);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
guardrails.push(built.value);
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
case 'agent': {
|
|
517
|
+
const built = buildAgent(unwrapped, relPath);
|
|
518
|
+
if (built.kind === 'err') {
|
|
519
|
+
fileErrors.push(built.error);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
agents.push(built.value);
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
case 'flow': {
|
|
526
|
+
const built = buildGraph(unwrapped, relPath);
|
|
527
|
+
if (built.kind === 'err') {
|
|
528
|
+
fileErrors.push(built.error);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
flows.push(built.value);
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const outputPath =
|
|
538
|
+
opts.outputPath !== undefined
|
|
539
|
+
? path.resolve(opts.outputPath)
|
|
540
|
+
: path.join(packDir, 'index.json');
|
|
541
|
+
|
|
542
|
+
const artifactVersion = opts.artifactVersion ?? (await computeAutoArtifactVersion(outputPath));
|
|
543
|
+
|
|
544
|
+
const publishedAt = opts.publishedAt ?? new Date().toISOString();
|
|
545
|
+
|
|
546
|
+
const index: Index = {
|
|
547
|
+
v: INDEX_ENVELOPE_VERSION,
|
|
548
|
+
packId: config.pack.id,
|
|
549
|
+
packVersion: config.pack.version,
|
|
550
|
+
artifactVersion,
|
|
551
|
+
publishedAt,
|
|
552
|
+
tools: sortById(tools),
|
|
553
|
+
guardrails: sortById(guardrails),
|
|
554
|
+
agents: sortById(agents),
|
|
555
|
+
flows: sortById(flows),
|
|
556
|
+
...(env.value !== undefined && { env: env.value }),
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const written = await atomicWriteJson(outputPath, index);
|
|
560
|
+
if (written.kind === 'err') return written;
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
kind: 'ok',
|
|
564
|
+
value: {
|
|
565
|
+
packId: config.pack.id,
|
|
566
|
+
packVersion: config.pack.version,
|
|
567
|
+
artifactVersion,
|
|
568
|
+
publishedAt,
|
|
569
|
+
counts: {
|
|
570
|
+
tools: tools.length,
|
|
571
|
+
guardrails: guardrails.length,
|
|
572
|
+
agents: agents.length,
|
|
573
|
+
flows: flows.length,
|
|
574
|
+
},
|
|
575
|
+
outputPath,
|
|
576
|
+
fileErrors,
|
|
577
|
+
},
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// -----------------------------------------------------------------------
|
|
582
|
+
// CLI entry
|
|
583
|
+
// -----------------------------------------------------------------------
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* The `kindgi-index` command (process entry: `kindgi-index-main`) — a
|
|
587
|
+
* pack image's indexer stage runs it over the image's bundles. Args:
|
|
588
|
+
*
|
|
589
|
+
* --pack-dir <path> root of the pack (required)
|
|
590
|
+
* --config <path> explicit config file (optional)
|
|
591
|
+
* --output <path> explicit output path (optional)
|
|
592
|
+
* --bundle-map <path> index a build: the bundle map (JSON, optional)
|
|
593
|
+
* --module-root <path> where the bundle map's paths resolve (optional)
|
|
594
|
+
* --artifact-version <str> pin artifact version (optional)
|
|
595
|
+
* --published-at <iso> pin publishedAt (optional; enables reproducible builds)
|
|
596
|
+
* --help
|
|
597
|
+
* --version
|
|
598
|
+
*/
|
|
599
|
+
export async function main(argv: readonly string[]): Promise<number> {
|
|
600
|
+
const parsed = parseArgs(argv);
|
|
601
|
+
if (parsed.kind === 'help') {
|
|
602
|
+
process.stdout.write(`${HELP_TEXT}\n`);
|
|
603
|
+
return 0;
|
|
604
|
+
}
|
|
605
|
+
if (parsed.kind === 'version') {
|
|
606
|
+
process.stdout.write('kindgi-index v0.0.0\n');
|
|
607
|
+
return 0;
|
|
608
|
+
}
|
|
609
|
+
if (parsed.kind === 'err') {
|
|
610
|
+
process.stderr.write(`kindgi-index: ${parsed.error}\n`);
|
|
611
|
+
return 2;
|
|
612
|
+
}
|
|
613
|
+
let options = parsed.value.options;
|
|
614
|
+
if (parsed.value.bundleMapPath !== undefined) {
|
|
615
|
+
const bundleMap = await readBundleMap(parsed.value.bundleMapPath);
|
|
616
|
+
if (bundleMap.kind === 'err') {
|
|
617
|
+
process.stderr.write(`kindgi-index: ${bundleMap.message}\n`);
|
|
618
|
+
return 2;
|
|
619
|
+
}
|
|
620
|
+
options = { ...options, bundleMap: bundleMap.value };
|
|
621
|
+
}
|
|
622
|
+
const outcome = await runIndexer(options);
|
|
623
|
+
if (outcome.kind === 'err') {
|
|
624
|
+
process.stderr.write(`kindgi-index: ${outcome.error.code}: ${outcome.error.message}\n`);
|
|
625
|
+
return 1;
|
|
626
|
+
}
|
|
627
|
+
process.stdout.write(`${JSON.stringify(outcome.value, null, 2)}\n`);
|
|
628
|
+
return 0;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export const HELP_TEXT = `kindgi-index — build-time pack manifest indexer
|
|
632
|
+
|
|
633
|
+
Usage:
|
|
634
|
+
kindgi-index --pack-dir <path> [--config <path>] [--output <path>]
|
|
635
|
+
[--bundle-map <path> [--module-root <path>]]
|
|
636
|
+
[--artifact-version <str>] [--published-at <iso>]
|
|
637
|
+
|
|
638
|
+
Options:
|
|
639
|
+
--pack-dir <path> Root of the pack (required).
|
|
640
|
+
--config <path> Explicit kindgi.config.* path.
|
|
641
|
+
--output <path> Output path for index.json (default: <packDir>/index.json).
|
|
642
|
+
--bundle-map <path> Index a build: JSON mapping each source path to its bundle.
|
|
643
|
+
--module-root <path> Where the bundle map's paths resolve (default: --pack-dir).
|
|
644
|
+
--artifact-version <str> Pin artifact version (default: YYYYMMDD.N auto).
|
|
645
|
+
--published-at <iso> Pin publishedAt for reproducible builds.
|
|
646
|
+
--help Show this help.
|
|
647
|
+
--version Print version and exit.`;
|
|
648
|
+
|
|
649
|
+
type ParsedArgs =
|
|
650
|
+
| {
|
|
651
|
+
readonly kind: 'ok';
|
|
652
|
+
readonly value: { readonly options: RunIndexerOptions; readonly bundleMapPath?: string };
|
|
653
|
+
}
|
|
654
|
+
| { readonly kind: 'help' }
|
|
655
|
+
| { readonly kind: 'version' }
|
|
656
|
+
| { readonly kind: 'err'; readonly error: string };
|
|
657
|
+
|
|
658
|
+
function parseArgs(argv: readonly string[]): ParsedArgs {
|
|
659
|
+
let packDir: string | undefined;
|
|
660
|
+
let configPath: string | undefined;
|
|
661
|
+
let outputPath: string | undefined;
|
|
662
|
+
let artifactVersion: string | undefined;
|
|
663
|
+
let publishedAt: string | undefined;
|
|
664
|
+
let bundleMapPath: string | undefined;
|
|
665
|
+
let moduleRoot: string | undefined;
|
|
666
|
+
|
|
667
|
+
let i = 0;
|
|
668
|
+
while (i < argv.length) {
|
|
669
|
+
const arg = argv[i];
|
|
670
|
+
if (arg === undefined) break;
|
|
671
|
+
if (arg === '--help' || arg === '-h') return { kind: 'help' };
|
|
672
|
+
if (arg === '--version' || arg === '-v') return { kind: 'version' };
|
|
673
|
+
|
|
674
|
+
const takeValue = (): string | { readonly err: string } => {
|
|
675
|
+
const value = argv[i + 1];
|
|
676
|
+
if (value === undefined) return { err: `${arg} requires a value` };
|
|
677
|
+
return value;
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
if (arg === '--pack-dir') {
|
|
681
|
+
const value = takeValue();
|
|
682
|
+
if (typeof value !== 'string') return { kind: 'err', error: value.err };
|
|
683
|
+
packDir = value;
|
|
684
|
+
i += 2;
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
if (arg === '--config') {
|
|
688
|
+
const value = takeValue();
|
|
689
|
+
if (typeof value !== 'string') return { kind: 'err', error: value.err };
|
|
690
|
+
configPath = value;
|
|
691
|
+
i += 2;
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
if (arg === '--output') {
|
|
695
|
+
const value = takeValue();
|
|
696
|
+
if (typeof value !== 'string') return { kind: 'err', error: value.err };
|
|
697
|
+
outputPath = value;
|
|
698
|
+
i += 2;
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
if (arg === '--artifact-version') {
|
|
702
|
+
const value = takeValue();
|
|
703
|
+
if (typeof value !== 'string') return { kind: 'err', error: value.err };
|
|
704
|
+
artifactVersion = value;
|
|
705
|
+
i += 2;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
if (arg === '--published-at') {
|
|
709
|
+
const value = takeValue();
|
|
710
|
+
if (typeof value !== 'string') return { kind: 'err', error: value.err };
|
|
711
|
+
publishedAt = value;
|
|
712
|
+
i += 2;
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
if (arg === '--bundle-map') {
|
|
716
|
+
const value = takeValue();
|
|
717
|
+
if (typeof value !== 'string') return { kind: 'err', error: value.err };
|
|
718
|
+
bundleMapPath = value;
|
|
719
|
+
i += 2;
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
if (arg === '--module-root') {
|
|
723
|
+
const value = takeValue();
|
|
724
|
+
if (typeof value !== 'string') return { kind: 'err', error: value.err };
|
|
725
|
+
moduleRoot = value;
|
|
726
|
+
i += 2;
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
return { kind: 'err', error: `Unknown argument: ${arg}` };
|
|
730
|
+
}
|
|
731
|
+
if (packDir === undefined) {
|
|
732
|
+
return { kind: 'err', error: '--pack-dir is required' };
|
|
733
|
+
}
|
|
734
|
+
if (moduleRoot !== undefined && bundleMapPath === undefined) {
|
|
735
|
+
return { kind: 'err', error: '--module-root goes with --bundle-map' };
|
|
736
|
+
}
|
|
737
|
+
const options: RunIndexerOptions = {
|
|
738
|
+
packDir,
|
|
739
|
+
...(configPath !== undefined && { configPath }),
|
|
740
|
+
...(outputPath !== undefined && { outputPath }),
|
|
741
|
+
...(artifactVersion !== undefined && { artifactVersion }),
|
|
742
|
+
...(publishedAt !== undefined && { publishedAt }),
|
|
743
|
+
...(moduleRoot !== undefined && { moduleRoot }),
|
|
744
|
+
};
|
|
745
|
+
return {
|
|
746
|
+
kind: 'ok',
|
|
747
|
+
value: { options, ...(bundleMapPath !== undefined && { bundleMapPath }) },
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* A bundle map file: a JSON object of source path → bundle path, both
|
|
753
|
+
* strings (`kindgi build` writes `dist/bundle-map.json`).
|
|
754
|
+
*/
|
|
755
|
+
export async function readBundleMap(
|
|
756
|
+
file: string,
|
|
757
|
+
): Promise<
|
|
758
|
+
| { readonly kind: 'ok'; readonly value: Readonly<Record<string, string>> }
|
|
759
|
+
| { readonly kind: 'err'; readonly message: string }
|
|
760
|
+
> {
|
|
761
|
+
let parsed: unknown;
|
|
762
|
+
try {
|
|
763
|
+
parsed = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
764
|
+
} catch (cause) {
|
|
765
|
+
return { kind: 'err', message: `Cannot read the bundle map ${file}: ${stringifyError(cause)}` };
|
|
766
|
+
}
|
|
767
|
+
if (
|
|
768
|
+
parsed === null ||
|
|
769
|
+
typeof parsed !== 'object' ||
|
|
770
|
+
Array.isArray(parsed) ||
|
|
771
|
+
!Object.values(parsed).every((v) => typeof v === 'string' && v !== '')
|
|
772
|
+
) {
|
|
773
|
+
return {
|
|
774
|
+
kind: 'err',
|
|
775
|
+
message: `The bundle map ${file} must be a JSON object of source path → bundle path.`,
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
return { kind: 'ok', value: parsed as Record<string, string> };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// -----------------------------------------------------------------------
|
|
782
|
+
// Config loading
|
|
783
|
+
// -----------------------------------------------------------------------
|
|
784
|
+
|
|
785
|
+
/** Config file names looked up at a pack root, in order. */
|
|
786
|
+
export const KINDGI_CONFIG_FILENAMES: readonly string[] = [
|
|
787
|
+
'kindgi.config.ts',
|
|
788
|
+
// `.mts` is always ESM — the config name for a CommonJS app, where a
|
|
789
|
+
// `.ts` config would be reparsed with a Node warning.
|
|
790
|
+
'kindgi.config.mts',
|
|
791
|
+
'kindgi.config.mjs',
|
|
792
|
+
'kindgi.config.js',
|
|
793
|
+
'kindgi.config.cjs',
|
|
794
|
+
];
|
|
795
|
+
|
|
796
|
+
export interface LoadKindgiConfigOptions {
|
|
797
|
+
/** Explicit config path (absolute or cwd-relative); skips the lookup. */
|
|
798
|
+
readonly configPath?: string;
|
|
799
|
+
/** Test hook — replaces the cache-busted dynamic `import()`. */
|
|
800
|
+
readonly importModule?: (fileUrl: string) => Promise<unknown>;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Load and minimally validate a pack's `kindgi.config.*` — the one
|
|
805
|
+
* loader behind the indexer and any tooling that reads pack config.
|
|
806
|
+
* Never throws:
|
|
807
|
+
* `config-not-found` when no config file exists, `config-parse-failed`
|
|
808
|
+
* (with the underlying cause) when it exists but cannot be imported or
|
|
809
|
+
* lacks `pack.id` / `pack.version`. Imports are cache-busted by mtime so
|
|
810
|
+
* a long-running process sees edits.
|
|
811
|
+
*/
|
|
812
|
+
export async function loadKindgiConfig(
|
|
813
|
+
packDir: string,
|
|
814
|
+
options: LoadKindgiConfigOptions = {},
|
|
815
|
+
): Promise<Result<KindgiConfig, IndexerError>> {
|
|
816
|
+
return loadConfig(
|
|
817
|
+
path.resolve(packDir),
|
|
818
|
+
options.configPath,
|
|
819
|
+
options.importModule ?? defaultImportModule,
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** `pyproject.toml` — a Python pack keeps its config in the `[tool.kindgi]` table. */
|
|
824
|
+
export const PYPROJECT_FILENAME = 'pyproject.toml';
|
|
825
|
+
|
|
826
|
+
export interface KindgiConfigFile {
|
|
827
|
+
readonly path: string;
|
|
828
|
+
/** `module`: a `kindgi.config.*`; `pyproject`: the `[tool.kindgi]` table of a `pyproject.toml`. */
|
|
829
|
+
readonly format: 'module' | 'pyproject';
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Where a pack's config lives: the first `kindgi.config.*` at `packDir`
|
|
834
|
+
* (`KINDGI_CONFIG_FILENAMES` order), else a `pyproject.toml` with a
|
|
835
|
+
* `[tool.kindgi]` table. `undefined` when neither — a `pyproject.toml`
|
|
836
|
+
* without the table is a Python project, not a pack.
|
|
837
|
+
*/
|
|
838
|
+
export async function findKindgiConfig(packDir: string): Promise<KindgiConfigFile | undefined> {
|
|
839
|
+
for (const name of KINDGI_CONFIG_FILENAMES) {
|
|
840
|
+
const candidate = path.join(packDir, name);
|
|
841
|
+
if (await exists(candidate)) return { path: candidate, format: 'module' };
|
|
842
|
+
}
|
|
843
|
+
const pyproject = path.join(packDir, PYPROJECT_FILENAME);
|
|
844
|
+
let text: string;
|
|
845
|
+
try {
|
|
846
|
+
text = await fs.readFile(pyproject, 'utf8');
|
|
847
|
+
} catch {
|
|
848
|
+
return undefined;
|
|
849
|
+
}
|
|
850
|
+
try {
|
|
851
|
+
const tool = (parseToml(text) as Record<string, unknown>).tool;
|
|
852
|
+
return isObject(tool) && 'kindgi' in tool
|
|
853
|
+
? { path: pyproject, format: 'pyproject' }
|
|
854
|
+
: undefined;
|
|
855
|
+
} catch {
|
|
856
|
+
// Unparsable: a pack only if it clearly meant to be one (the loader reports the error).
|
|
857
|
+
return /^\s*\[tool\.kindgi[\].]/m.test(text)
|
|
858
|
+
? { path: pyproject, format: 'pyproject' }
|
|
859
|
+
: undefined;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
async function exists(file: string): Promise<boolean> {
|
|
864
|
+
try {
|
|
865
|
+
await fs.access(file);
|
|
866
|
+
return true;
|
|
867
|
+
} catch {
|
|
868
|
+
return false;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async function loadConfig(
|
|
873
|
+
packDir: string,
|
|
874
|
+
explicitPath: string | undefined,
|
|
875
|
+
importModule: (fileUrl: string) => Promise<unknown>,
|
|
876
|
+
): Promise<Result<KindgiConfig, IndexerError>> {
|
|
877
|
+
let file: KindgiConfigFile | undefined;
|
|
878
|
+
if (explicitPath !== undefined) {
|
|
879
|
+
const resolved = path.resolve(explicitPath);
|
|
880
|
+
if (await exists(resolved)) {
|
|
881
|
+
const format = path.basename(resolved) === PYPROJECT_FILENAME ? 'pyproject' : 'module';
|
|
882
|
+
file = { path: resolved, format };
|
|
883
|
+
}
|
|
884
|
+
} else {
|
|
885
|
+
file = await findKindgiConfig(packDir);
|
|
886
|
+
}
|
|
887
|
+
if (file === undefined) {
|
|
888
|
+
return {
|
|
889
|
+
kind: 'err',
|
|
890
|
+
error: {
|
|
891
|
+
code: 'config-not-found',
|
|
892
|
+
message: `No kindgi.config.{ts,mts,mjs,js,cjs}, or pyproject.toml with a [tool.kindgi] table, found at pack root ${packDir}`,
|
|
893
|
+
},
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
const read =
|
|
897
|
+
file.format === 'pyproject'
|
|
898
|
+
? await readPyprojectTable(file.path)
|
|
899
|
+
: await importConfigModule(file.path, importModule);
|
|
900
|
+
if (read.kind === 'err') return read;
|
|
901
|
+
const checked = checkConfig(read.value, file.path);
|
|
902
|
+
if (checked.kind === 'err') return checked;
|
|
903
|
+
const record = checked.value;
|
|
904
|
+
return {
|
|
905
|
+
kind: 'ok',
|
|
906
|
+
value:
|
|
907
|
+
file.format === 'pyproject' && record.language === undefined
|
|
908
|
+
? { ...record, language: 'python' }
|
|
909
|
+
: record,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function parseFailed(
|
|
914
|
+
filePath: string,
|
|
915
|
+
message: string,
|
|
916
|
+
cause?: unknown,
|
|
917
|
+
): Result<never, IndexerError> {
|
|
918
|
+
return {
|
|
919
|
+
kind: 'err',
|
|
920
|
+
error: {
|
|
921
|
+
code: 'config-parse-failed',
|
|
922
|
+
message,
|
|
923
|
+
filePath,
|
|
924
|
+
...(cause !== undefined && { cause: serializeCause(cause) }),
|
|
925
|
+
},
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
async function importConfigModule(
|
|
930
|
+
filePath: string,
|
|
931
|
+
importModule: (fileUrl: string) => Promise<unknown>,
|
|
932
|
+
): Promise<Result<Record<string, unknown>, IndexerError>> {
|
|
933
|
+
let module_: unknown;
|
|
934
|
+
try {
|
|
935
|
+
module_ = await importModule(pathToFileURL(filePath).href);
|
|
936
|
+
} catch (cause) {
|
|
937
|
+
return parseFailed(filePath, `Failed to import ${filePath}: ${stringifyError(cause)}`, cause);
|
|
938
|
+
}
|
|
939
|
+
const defExport = extractDefaultExport(module_);
|
|
940
|
+
if (defExport === undefined || typeof defExport !== 'object' || defExport === null) {
|
|
941
|
+
return parseFailed(
|
|
942
|
+
filePath,
|
|
943
|
+
`Config file ${filePath} has no default export (or it is not an object)`,
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
return { kind: 'ok', value: defExport as Record<string, unknown> };
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
async function readPyprojectTable(
|
|
950
|
+
filePath: string,
|
|
951
|
+
): Promise<Result<Record<string, unknown>, IndexerError>> {
|
|
952
|
+
let document: Record<string, unknown>;
|
|
953
|
+
try {
|
|
954
|
+
document = parseToml(await fs.readFile(filePath, 'utf8')) as Record<string, unknown>;
|
|
955
|
+
} catch (cause) {
|
|
956
|
+
return parseFailed(filePath, `Failed to read ${filePath}: ${stringifyError(cause)}`, cause);
|
|
957
|
+
}
|
|
958
|
+
const tool = document.tool;
|
|
959
|
+
const table = isObject(tool) ? tool.kindgi : undefined;
|
|
960
|
+
if (!isObject(table)) {
|
|
961
|
+
return {
|
|
962
|
+
kind: 'err',
|
|
963
|
+
error: {
|
|
964
|
+
code: 'config-not-found',
|
|
965
|
+
message: `${filePath} has no [tool.kindgi] table`,
|
|
966
|
+
filePath,
|
|
967
|
+
},
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
return { kind: 'ok', value: table as Record<string, unknown> };
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** The checks every config passes, whatever file it came from. */
|
|
974
|
+
function checkConfig(
|
|
975
|
+
record: Record<string, unknown>,
|
|
976
|
+
filePath: string,
|
|
977
|
+
): Result<KindgiConfig, IndexerError> {
|
|
978
|
+
const pack = record.pack;
|
|
979
|
+
if (typeof pack !== 'object' || pack === null) {
|
|
980
|
+
return parseFailed(
|
|
981
|
+
filePath,
|
|
982
|
+
`Config file ${filePath}: 'pack' field is missing or not an object`,
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
const packRec = pack as Record<string, unknown>;
|
|
986
|
+
if (typeof packRec.id !== 'string' || packRec.id.length === 0) {
|
|
987
|
+
return parseFailed(
|
|
988
|
+
filePath,
|
|
989
|
+
`Config file ${filePath}: 'pack.id' is missing or not a non-empty string`,
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
if (typeof packRec.version !== 'string' || packRec.version.length === 0) {
|
|
993
|
+
return parseFailed(
|
|
994
|
+
filePath,
|
|
995
|
+
`Config file ${filePath}: 'pack.version' is missing or not a non-empty string`,
|
|
996
|
+
);
|
|
997
|
+
}
|
|
998
|
+
if (record.language !== undefined && record.language !== 'node' && record.language !== 'python') {
|
|
999
|
+
return parseFailed(filePath, `Config file ${filePath}: 'language' must be "node" or "python"`);
|
|
1000
|
+
}
|
|
1001
|
+
return { kind: 'ok', value: record as KindgiConfig };
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// -----------------------------------------------------------------------
|
|
1005
|
+
// Discovery
|
|
1006
|
+
// -----------------------------------------------------------------------
|
|
1007
|
+
|
|
1008
|
+
interface DiscoveredFile {
|
|
1009
|
+
readonly relPath: string;
|
|
1010
|
+
/**
|
|
1011
|
+
* `undefined` when the matching pattern was custom (not the default
|
|
1012
|
+
* `<folder>/**\/*.{ts,js,mjs}` shape). Structural detection resolves
|
|
1013
|
+
* the kind from the default export's shape.
|
|
1014
|
+
*/
|
|
1015
|
+
readonly expectedKind: PrimitiveKind | undefined;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const DEFAULT_FOLDERS: Record<PrimitiveKind, string> = {
|
|
1019
|
+
tool: 'tools',
|
|
1020
|
+
guardrail: 'guardrails',
|
|
1021
|
+
agent: 'agents',
|
|
1022
|
+
flow: 'flows',
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
async function discoverFiles(
|
|
1026
|
+
packDir: string,
|
|
1027
|
+
discovery: Required<DiscoveryConfig>,
|
|
1028
|
+
): Promise<readonly DiscoveredFile[]> {
|
|
1029
|
+
const seen = new Map<string, PrimitiveKind | undefined>();
|
|
1030
|
+
for (const { kind, pattern } of kindPatterns(discovery)) {
|
|
1031
|
+
for (const rel of await globMatch(packDir, pattern)) classifyDiscovered(seen, rel, kind);
|
|
1032
|
+
}
|
|
1033
|
+
return sortedDiscovered(seen);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** A bundle map's source paths, classified as `discoverFiles` classifies what it finds. */
|
|
1037
|
+
function discoverInBundleMap(
|
|
1038
|
+
bundleMap: Readonly<Record<string, string>>,
|
|
1039
|
+
discovery: Required<DiscoveryConfig>,
|
|
1040
|
+
): readonly DiscoveredFile[] {
|
|
1041
|
+
const sources = Object.keys(bundleMap).sort();
|
|
1042
|
+
const seen = new Map<string, PrimitiveKind | undefined>();
|
|
1043
|
+
for (const { kind, pattern } of kindPatterns(discovery)) {
|
|
1044
|
+
const regex = globToRegex(pattern);
|
|
1045
|
+
for (const rel of sources) if (regex.test(rel)) classifyDiscovered(seen, rel, kind);
|
|
1046
|
+
}
|
|
1047
|
+
return sortedDiscovered(seen);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function kindPatterns(
|
|
1051
|
+
discovery: Required<DiscoveryConfig>,
|
|
1052
|
+
): readonly { readonly kind: PrimitiveKind; readonly pattern: string }[] {
|
|
1053
|
+
return [
|
|
1054
|
+
{ kind: 'tool', pattern: discovery.tools },
|
|
1055
|
+
{ kind: 'guardrail', pattern: discovery.guardrails },
|
|
1056
|
+
{ kind: 'agent', pattern: discovery.agents },
|
|
1057
|
+
{ kind: 'flow', pattern: discovery.flows },
|
|
1058
|
+
];
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/** Record `rel`, matched by `kind`'s pattern, unless it's a test file or already seen. */
|
|
1062
|
+
function classifyDiscovered(
|
|
1063
|
+
seen: Map<string, PrimitiveKind | undefined>,
|
|
1064
|
+
rel: string,
|
|
1065
|
+
kind: PrimitiveKind,
|
|
1066
|
+
): void {
|
|
1067
|
+
// Skip test / spec files — they live next to the primitive
|
|
1068
|
+
// (`tools/echo/index.ts` + `tools/echo/index.test.ts`)
|
|
1069
|
+
// and would break the indexer if imported as a primitive
|
|
1070
|
+
// module. Matches conventional test-file suffixes across
|
|
1071
|
+
// vitest / jest / node:test.
|
|
1072
|
+
if (TEST_FILE_REGEX.test(rel)) return;
|
|
1073
|
+
if (seen.has(rel)) return;
|
|
1074
|
+
const firstSegment = rel.split('/')[0];
|
|
1075
|
+
const inDefaultFolder = firstSegment === DEFAULT_FOLDERS[kind];
|
|
1076
|
+
seen.set(rel, inDefaultFolder ? kind : undefined);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function sortedDiscovered(
|
|
1080
|
+
seen: ReadonlyMap<string, PrimitiveKind | undefined>,
|
|
1081
|
+
): readonly DiscoveredFile[] {
|
|
1082
|
+
const out: DiscoveredFile[] = [];
|
|
1083
|
+
for (const [relPath, expectedKind] of seen) out.push({ relPath, expectedKind });
|
|
1084
|
+
out.sort((a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0));
|
|
1085
|
+
return out;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Minimal glob matcher supporting `**` (any depth), `*` (single-segment
|
|
1090
|
+
* wildcard), and `{a,b,c}` alternation. Sufficient for the default
|
|
1091
|
+
* discovery patterns and typical custom overrides. Negations and
|
|
1092
|
+
* extglob patterns are not supported.
|
|
1093
|
+
*/
|
|
1094
|
+
async function globMatch(root: string, pattern: string): Promise<readonly string[]> {
|
|
1095
|
+
const regex = globToRegex(pattern);
|
|
1096
|
+
const matches: string[] = [];
|
|
1097
|
+
// Walk only the pattern's static prefix — a pack embedded in a large
|
|
1098
|
+
// app (`kindgi/tools/**`) must not crawl the whole host repo.
|
|
1099
|
+
await walk(root, globStaticPrefix(pattern), matches, regex);
|
|
1100
|
+
return matches;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
async function walk(root: string, rel: string, matches: string[], regex: RegExp): Promise<void> {
|
|
1104
|
+
const abs = rel === '' ? root : path.join(root, rel);
|
|
1105
|
+
let entries: import('node:fs').Dirent[];
|
|
1106
|
+
try {
|
|
1107
|
+
entries = await fs.readdir(abs, { withFileTypes: true });
|
|
1108
|
+
} catch {
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
for (const entry of entries) {
|
|
1112
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
|
1113
|
+
const childRel = rel === '' ? entry.name : `${rel}/${entry.name}`;
|
|
1114
|
+
if (entry.isDirectory()) {
|
|
1115
|
+
await walk(root, childRel, matches, regex);
|
|
1116
|
+
} else if (entry.isFile()) {
|
|
1117
|
+
if (regex.test(childRel)) matches.push(childRel);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// -----------------------------------------------------------------------
|
|
1123
|
+
// Module import + default export extraction
|
|
1124
|
+
// -----------------------------------------------------------------------
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Cache-busted dynamic import. Node's module cache is keyed by URL, so
|
|
1128
|
+
* a plain `import(fileUrl)` inside a long-running process (a dev watch
|
|
1129
|
+
* loop) returns the *originally loaded* module even after the author
|
|
1130
|
+
* edits the file on disk — the indexer would then re-emit stale
|
|
1131
|
+
* manifests every watch tick.
|
|
1132
|
+
*
|
|
1133
|
+
* Fix: append `?v=<mtimeMs>` to the URL. Node treats each distinct URL
|
|
1134
|
+
* as a distinct module entry, so an edited file (with a newer mtime)
|
|
1135
|
+
* triggers a fresh load; an unchanged file keeps the same URL and hits
|
|
1136
|
+
* the cache — no wasted work, no memory churn.
|
|
1137
|
+
*
|
|
1138
|
+
* Best-effort: if `fs.stat` fails (e.g. an unusual URL scheme or a
|
|
1139
|
+
* race), fall back to the plain `import()` so the indexer still
|
|
1140
|
+
* completes — logged behavior remains the same, just without cache
|
|
1141
|
+
* invalidation.
|
|
1142
|
+
*/
|
|
1143
|
+
async function defaultImportModule(fileUrl: string): Promise<unknown> {
|
|
1144
|
+
try {
|
|
1145
|
+
const abs = fileURLToPath(fileUrl);
|
|
1146
|
+
const { mtimeMs } = await fs.stat(abs);
|
|
1147
|
+
return await import(`${fileUrl}?v=${mtimeMs}`);
|
|
1148
|
+
} catch {
|
|
1149
|
+
return await import(fileUrl);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function extractDefaultExport(module_: unknown): unknown | undefined {
|
|
1154
|
+
if (module_ === null || typeof module_ !== 'object') return undefined;
|
|
1155
|
+
const record = module_ as Record<string, unknown>;
|
|
1156
|
+
return record.default;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// -----------------------------------------------------------------------
|
|
1160
|
+
// Result-wrapper handling
|
|
1161
|
+
// -----------------------------------------------------------------------
|
|
1162
|
+
|
|
1163
|
+
function detectResultError(value: unknown): unknown | undefined {
|
|
1164
|
+
if (value === null || typeof value !== 'object') return undefined;
|
|
1165
|
+
const record = value as Record<string, unknown>;
|
|
1166
|
+
if (record.kind === 'err' && 'error' in record) return record.error;
|
|
1167
|
+
return undefined;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function unwrapResult(value: unknown): unknown {
|
|
1171
|
+
if (value === null || typeof value !== 'object') return value;
|
|
1172
|
+
const record = value as Record<string, unknown>;
|
|
1173
|
+
if (record.kind === 'ok' && 'value' in record) return record.value;
|
|
1174
|
+
return value;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function resultErrorMessage(err: unknown): string {
|
|
1178
|
+
if (err === null || typeof err !== 'object') return String(err);
|
|
1179
|
+
const rec = err as Record<string, unknown>;
|
|
1180
|
+
if (typeof rec.message === 'string') return rec.message;
|
|
1181
|
+
return JSON.stringify(err);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// -----------------------------------------------------------------------
|
|
1185
|
+
// Structural kind detection
|
|
1186
|
+
// -----------------------------------------------------------------------
|
|
1187
|
+
|
|
1188
|
+
function detectKind(value: unknown): PrimitiveKind | undefined {
|
|
1189
|
+
if (value === null || typeof value !== 'object') return undefined;
|
|
1190
|
+
const rec = value as Record<string, unknown>;
|
|
1191
|
+
if (typeof rec.handler === 'function' && ('input' in rec || 'inputZod' in rec)) {
|
|
1192
|
+
return 'tool';
|
|
1193
|
+
}
|
|
1194
|
+
const isGuardrailAction =
|
|
1195
|
+
typeof rec.action === 'object' &&
|
|
1196
|
+
rec.action !== null &&
|
|
1197
|
+
'on-violation' in (rec.action as Record<string, unknown>);
|
|
1198
|
+
if (isGuardrailAction && 'kind' in rec) return 'guardrail';
|
|
1199
|
+
if (typeof rec.instructions === 'string' && Array.isArray(rec.tools)) return 'agent';
|
|
1200
|
+
if (Array.isArray(rec.nodes) && Array.isArray(rec.edges)) return 'flow';
|
|
1201
|
+
return undefined;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// -----------------------------------------------------------------------
|
|
1205
|
+
// Manifest builders
|
|
1206
|
+
// -----------------------------------------------------------------------
|
|
1207
|
+
|
|
1208
|
+
function buildTool(
|
|
1209
|
+
raw: unknown,
|
|
1210
|
+
relPath: string,
|
|
1211
|
+
converter: ZodConverter | undefined,
|
|
1212
|
+
): Result<IndexedTool, IndexerError> {
|
|
1213
|
+
const rec = raw as Record<string, unknown>;
|
|
1214
|
+
if (typeof rec.id !== 'string') {
|
|
1215
|
+
return manifestErr(relPath, `'id' is missing or not a string`);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const inputWire = resolveSchema(rec, 'input', 'inputZod', relPath, converter);
|
|
1219
|
+
if (inputWire.kind === 'err') return inputWire;
|
|
1220
|
+
const outputWire = resolveSchema(rec, 'output', 'outputZod', relPath, converter);
|
|
1221
|
+
if (outputWire.kind === 'err') return outputWire;
|
|
1222
|
+
|
|
1223
|
+
const tool: IndexedTool = {
|
|
1224
|
+
id: rec.id,
|
|
1225
|
+
...(typeof rec.description === 'string' && { description: rec.description }),
|
|
1226
|
+
...(typeof rec.version === 'string' && { version: rec.version }),
|
|
1227
|
+
input: inputWire.value,
|
|
1228
|
+
output: outputWire.value,
|
|
1229
|
+
...(Array.isArray(rec.effects) && {
|
|
1230
|
+
effects: rec.effects as readonly Readonly<Record<string, unknown>>[],
|
|
1231
|
+
}),
|
|
1232
|
+
...(Array.isArray(rec.needs) && {
|
|
1233
|
+
needs: rec.needs as readonly Readonly<Record<string, unknown>>[],
|
|
1234
|
+
}),
|
|
1235
|
+
...(isObject(rec.needsSpec) && {
|
|
1236
|
+
needsSpec: rec.needsSpec as Readonly<Record<string, unknown>>,
|
|
1237
|
+
}),
|
|
1238
|
+
...(typeof rec.sandbox === 'string' && { sandbox: rec.sandbox }),
|
|
1239
|
+
...(isObject(rec.limits) && {
|
|
1240
|
+
limits: rec.limits as Readonly<Record<string, unknown>>,
|
|
1241
|
+
}),
|
|
1242
|
+
...(isObject(rec.network) && {
|
|
1243
|
+
network: rec.network as Readonly<Record<string, unknown>>,
|
|
1244
|
+
}),
|
|
1245
|
+
...(isObject(rec.spec) && { spec: rec.spec as Readonly<Record<string, unknown>> }),
|
|
1246
|
+
...(typeof rec.mutating === 'boolean' && { mutating: rec.mutating }),
|
|
1247
|
+
modulePath: normalizeModulePath(relPath),
|
|
1248
|
+
};
|
|
1249
|
+
return { kind: 'ok', value: tool };
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function buildGuardrail(
|
|
1253
|
+
raw: unknown,
|
|
1254
|
+
relPath: string,
|
|
1255
|
+
converter: ZodConverter | undefined,
|
|
1256
|
+
): Result<IndexedGuardrail, IndexerError> {
|
|
1257
|
+
const rec = raw as Record<string, unknown>;
|
|
1258
|
+
if (typeof rec.id !== 'string') {
|
|
1259
|
+
return manifestErr(relPath, `'id' is missing or not a string`);
|
|
1260
|
+
}
|
|
1261
|
+
if (typeof rec.kind !== 'string') {
|
|
1262
|
+
return manifestErr(relPath, `'kind' is missing or not a string`);
|
|
1263
|
+
}
|
|
1264
|
+
if (!isObject(rec.action)) {
|
|
1265
|
+
return manifestErr(relPath, `'action' is missing or not an object`);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// configSchema resolution: look in three places, in priority order:
|
|
1269
|
+
// 1. guardrail.configZod (top-level, DefinedCheck-style)
|
|
1270
|
+
// 2. guardrail.check.configZod (nested inside the check object)
|
|
1271
|
+
// 3. guardrail.configSchema / guardrail.check.configSchema (already JSON Schema)
|
|
1272
|
+
const check = isObject(rec.check) ? (rec.check as Record<string, unknown>) : undefined;
|
|
1273
|
+
const zodConfig =
|
|
1274
|
+
(isZodSchema(rec.configZod) ? (rec.configZod as ZodLikeSchema) : undefined) ??
|
|
1275
|
+
(check && isZodSchema(check.configZod) ? (check.configZod as ZodLikeSchema) : undefined);
|
|
1276
|
+
let configSchema: Readonly<Record<string, unknown>> | undefined;
|
|
1277
|
+
if (zodConfig !== undefined) {
|
|
1278
|
+
const converted = toJSONSchemaSync(zodConfig, converter, 'input');
|
|
1279
|
+
if (converted.kind === 'err') {
|
|
1280
|
+
return {
|
|
1281
|
+
kind: 'err',
|
|
1282
|
+
error: {
|
|
1283
|
+
code: 'zod-conversion-failed',
|
|
1284
|
+
message: `Guardrail ${rec.id} configSchema Zod conversion failed: ${converted.error.message}`,
|
|
1285
|
+
filePath: relPath,
|
|
1286
|
+
field: 'configSchema',
|
|
1287
|
+
cause: converted.error.cause,
|
|
1288
|
+
},
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
configSchema = converted.value;
|
|
1292
|
+
} else if (isObject(rec.configSchema)) {
|
|
1293
|
+
configSchema = rec.configSchema as Readonly<Record<string, unknown>>;
|
|
1294
|
+
} else if (check !== undefined && isObject(check.configSchema)) {
|
|
1295
|
+
configSchema = check.configSchema as Readonly<Record<string, unknown>>;
|
|
1296
|
+
} else if (check !== undefined && isObject(check.configJsonSchema)) {
|
|
1297
|
+
// `defineCheck` (guardrails pkg) already stores the converted JSON
|
|
1298
|
+
// Schema on `configJsonSchema` — accept that shape without a second
|
|
1299
|
+
// conversion pass.
|
|
1300
|
+
configSchema = check.configJsonSchema as Readonly<Record<string, unknown>>;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
const checkId: string | undefined =
|
|
1304
|
+
typeof rec.check === 'string'
|
|
1305
|
+
? rec.check
|
|
1306
|
+
: check !== undefined && typeof check.id === 'string'
|
|
1307
|
+
? check.id
|
|
1308
|
+
: undefined;
|
|
1309
|
+
|
|
1310
|
+
const guardrail: IndexedGuardrail = {
|
|
1311
|
+
id: rec.id,
|
|
1312
|
+
...(typeof rec.name === 'string' && { name: rec.name }),
|
|
1313
|
+
kind: rec.kind,
|
|
1314
|
+
action: rec.action as Readonly<Record<string, unknown>>,
|
|
1315
|
+
...(typeof rec.severity === 'string' && { severity: rec.severity }),
|
|
1316
|
+
...(isObject(rec.scope) && {
|
|
1317
|
+
scope: rec.scope as Readonly<Record<string, unknown>>,
|
|
1318
|
+
}),
|
|
1319
|
+
checkModulePath: normalizeModulePath(relPath),
|
|
1320
|
+
...(checkId !== undefined && { checkId }),
|
|
1321
|
+
...(configSchema !== undefined && { configSchema }),
|
|
1322
|
+
...(isObject(rec.config) && { config: rec.config as Readonly<Record<string, unknown>> }),
|
|
1323
|
+
...(typeof rec.sandbox === 'string' && { sandbox: rec.sandbox }),
|
|
1324
|
+
...(isObject(rec.limits) && {
|
|
1325
|
+
limits: rec.limits as Readonly<Record<string, unknown>>,
|
|
1326
|
+
}),
|
|
1327
|
+
...(isObject(rec.network) && {
|
|
1328
|
+
network: rec.network as Readonly<Record<string, unknown>>,
|
|
1329
|
+
}),
|
|
1330
|
+
};
|
|
1331
|
+
return { kind: 'ok', value: guardrail };
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function buildAgent(raw: unknown, relPath: string): Result<IndexedAgent, IndexerError> {
|
|
1335
|
+
const rec = raw as Record<string, unknown>;
|
|
1336
|
+
if (typeof rec.id !== 'string') {
|
|
1337
|
+
return manifestErr(relPath, `'id' is missing or not a string`);
|
|
1338
|
+
}
|
|
1339
|
+
if (typeof rec.version !== 'string') {
|
|
1340
|
+
return manifestErr(relPath, `'version' is missing or not a string`);
|
|
1341
|
+
}
|
|
1342
|
+
if (typeof rec.name !== 'string') {
|
|
1343
|
+
return manifestErr(relPath, `'name' is missing or not a string`);
|
|
1344
|
+
}
|
|
1345
|
+
if (typeof rec.instructions !== 'string') {
|
|
1346
|
+
return manifestErr(relPath, `'instructions' is missing or not a string`);
|
|
1347
|
+
}
|
|
1348
|
+
if (!Array.isArray(rec.capabilities)) {
|
|
1349
|
+
return manifestErr(relPath, `'capabilities' is missing or not an array`);
|
|
1350
|
+
}
|
|
1351
|
+
if (!Array.isArray(rec.tools)) {
|
|
1352
|
+
return manifestErr(relPath, `'tools' is missing or not an array`);
|
|
1353
|
+
}
|
|
1354
|
+
const agent: IndexedAgent = {
|
|
1355
|
+
id: rec.id,
|
|
1356
|
+
version: rec.version,
|
|
1357
|
+
name: rec.name,
|
|
1358
|
+
instructions: rec.instructions,
|
|
1359
|
+
capabilities: rec.capabilities as readonly unknown[],
|
|
1360
|
+
tools: rec.tools as readonly { readonly id: string; readonly version: string }[],
|
|
1361
|
+
...optionalAgentFields(rec),
|
|
1362
|
+
modulePath: normalizeModulePath(relPath),
|
|
1363
|
+
};
|
|
1364
|
+
return { kind: 'ok', value: agent };
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/** The agent's optional fields, carried when present with the right shape. */
|
|
1368
|
+
function optionalAgentFields(rec: Record<string, unknown>): Partial<IndexedAgent> {
|
|
1369
|
+
const out: Record<string, unknown> = {};
|
|
1370
|
+
for (const key of ['retrieval', 'guardrails', 'parameters', 'tags'] as const) {
|
|
1371
|
+
if (Array.isArray(rec[key])) out[key] = rec[key];
|
|
1372
|
+
}
|
|
1373
|
+
for (const key of ['budget', 'conversationPolicy', 'output', 'toolErrors'] as const) {
|
|
1374
|
+
if (isObject(rec[key])) out[key] = rec[key];
|
|
1375
|
+
}
|
|
1376
|
+
for (const key of ['preferredProvider', 'preferredModel', 'description'] as const) {
|
|
1377
|
+
if (typeof rec[key] === 'string') out[key] = rec[key];
|
|
1378
|
+
}
|
|
1379
|
+
return out as Partial<IndexedAgent>;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* Validate a discovered flow with the same loader the API uses, so a
|
|
1384
|
+
* broken flow fails indexing with the loader's message instead of
|
|
1385
|
+
* failing later at publish or run time, and carry every field it has.
|
|
1386
|
+
*/
|
|
1387
|
+
function buildGraph(raw: unknown, relPath: string): Result<IndexedFlow, IndexerError> {
|
|
1388
|
+
const loaded = loadFlow(raw);
|
|
1389
|
+
if (loaded.kind === 'err') return manifestErr(relPath, loaded.error.message);
|
|
1390
|
+
const f = loaded.value;
|
|
1391
|
+
const flow: IndexedFlow = {
|
|
1392
|
+
id: f.id as unknown as string,
|
|
1393
|
+
version: f.version,
|
|
1394
|
+
...(f.name !== undefined && { name: f.name }),
|
|
1395
|
+
...(f.description !== undefined && { description: f.description }),
|
|
1396
|
+
nodes: f.nodes,
|
|
1397
|
+
edges: f.edges,
|
|
1398
|
+
...(f.maxParallelism !== undefined && { maxParallelism: f.maxParallelism }),
|
|
1399
|
+
...(f.metadata !== undefined && { metadata: f.metadata }),
|
|
1400
|
+
...(f.output !== undefined && { output: f.output }),
|
|
1401
|
+
kernelPayloadVersion: KERNEL_PAYLOAD_VERSION,
|
|
1402
|
+
modulePath: normalizeModulePath(relPath),
|
|
1403
|
+
};
|
|
1404
|
+
return { kind: 'ok', value: flow };
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function manifestErr(relPath: string, msg: string): Result<never, IndexerError> {
|
|
1408
|
+
return {
|
|
1409
|
+
kind: 'err',
|
|
1410
|
+
error: {
|
|
1411
|
+
code: 'manifest-validation-failed',
|
|
1412
|
+
message: `${relPath}: ${msg}`,
|
|
1413
|
+
filePath: relPath,
|
|
1414
|
+
},
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// -----------------------------------------------------------------------
|
|
1419
|
+
// Schema resolution — Zod → JSON Schema pass
|
|
1420
|
+
// -----------------------------------------------------------------------
|
|
1421
|
+
|
|
1422
|
+
function resolveSchema(
|
|
1423
|
+
rec: Record<string, unknown>,
|
|
1424
|
+
wireField: 'input' | 'output',
|
|
1425
|
+
zodField: string,
|
|
1426
|
+
relPath: string,
|
|
1427
|
+
converter: ZodConverter | undefined,
|
|
1428
|
+
): Result<Readonly<Record<string, unknown>>, IndexerError> {
|
|
1429
|
+
// A tool's `input` is what a caller sends; its `output`, what it returns.
|
|
1430
|
+
const side = wireField;
|
|
1431
|
+
const zodValue = rec[zodField];
|
|
1432
|
+
if (zodValue !== undefined && isZodSchema(zodValue)) {
|
|
1433
|
+
const converted = toJSONSchemaSync(zodValue as AnySchema, converter, side);
|
|
1434
|
+
if (converted.kind === 'err') {
|
|
1435
|
+
return {
|
|
1436
|
+
kind: 'err',
|
|
1437
|
+
error: {
|
|
1438
|
+
code: 'zod-conversion-failed',
|
|
1439
|
+
message: `${relPath}: Zod conversion for '${zodField}' failed: ${converted.error.message}`,
|
|
1440
|
+
filePath: relPath,
|
|
1441
|
+
field: zodField,
|
|
1442
|
+
cause: converted.error.cause,
|
|
1443
|
+
},
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
return { kind: 'ok', value: converted.value };
|
|
1447
|
+
}
|
|
1448
|
+
const wireValue = rec[wireField];
|
|
1449
|
+
// `wireValue` may itself be a Zod schema (author-time authored with Zod
|
|
1450
|
+
// but not yet run through defineTool). Convert lazily in that case.
|
|
1451
|
+
if (isZodSchema(wireValue)) {
|
|
1452
|
+
const converted = toJSONSchemaSync(wireValue as AnySchema, converter, side);
|
|
1453
|
+
if (converted.kind === 'err') {
|
|
1454
|
+
return {
|
|
1455
|
+
kind: 'err',
|
|
1456
|
+
error: {
|
|
1457
|
+
code: 'zod-conversion-failed',
|
|
1458
|
+
message: `${relPath}: Zod conversion for '${wireField}' failed: ${converted.error.message}`,
|
|
1459
|
+
filePath: relPath,
|
|
1460
|
+
field: wireField,
|
|
1461
|
+
cause: converted.error.cause,
|
|
1462
|
+
},
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
return { kind: 'ok', value: converted.value };
|
|
1466
|
+
}
|
|
1467
|
+
if (!isObject(wireValue)) {
|
|
1468
|
+
return {
|
|
1469
|
+
kind: 'err',
|
|
1470
|
+
error: {
|
|
1471
|
+
code: 'manifest-validation-failed',
|
|
1472
|
+
message: `${relPath}: '${wireField}' is missing or not an object`,
|
|
1473
|
+
filePath: relPath,
|
|
1474
|
+
field: wireField,
|
|
1475
|
+
},
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
return { kind: 'ok', value: wireValue as Readonly<Record<string, unknown>> };
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// -----------------------------------------------------------------------
|
|
1482
|
+
// Output serialization — atomic write + auto artifact version
|
|
1483
|
+
// -----------------------------------------------------------------------
|
|
1484
|
+
|
|
1485
|
+
async function atomicWriteJson(
|
|
1486
|
+
outputPath: string,
|
|
1487
|
+
index: Index,
|
|
1488
|
+
): Promise<Result<undefined, IndexerError>> {
|
|
1489
|
+
const tmpPath = `${outputPath}.tmp-${process.pid}-${Date.now()}`;
|
|
1490
|
+
const serialized = `${stableStringify(index)}\n`;
|
|
1491
|
+
try {
|
|
1492
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
1493
|
+
await fs.writeFile(tmpPath, serialized, 'utf8');
|
|
1494
|
+
await fs.rename(tmpPath, outputPath);
|
|
1495
|
+
} catch (cause) {
|
|
1496
|
+
// Best-effort tmp cleanup — swallow errors here (the original write
|
|
1497
|
+
// error is what the caller needs).
|
|
1498
|
+
try {
|
|
1499
|
+
await fs.unlink(tmpPath);
|
|
1500
|
+
} catch {
|
|
1501
|
+
// ignore
|
|
1502
|
+
}
|
|
1503
|
+
return {
|
|
1504
|
+
kind: 'err',
|
|
1505
|
+
error: {
|
|
1506
|
+
code: 'output-write-failed',
|
|
1507
|
+
message: `Failed to write ${outputPath}: ${stringifyError(cause)}`,
|
|
1508
|
+
filePath: outputPath,
|
|
1509
|
+
cause: serializeCause(cause),
|
|
1510
|
+
},
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
return { kind: 'ok', value: undefined };
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/**
|
|
1517
|
+
* Stable JSON serializer — keys sorted at every level. Guarantees
|
|
1518
|
+
* byte-identical output for byte-identical semantic input, which is the
|
|
1519
|
+
* property the local integrity gate depends on.
|
|
1520
|
+
*/
|
|
1521
|
+
function stableStringify(value: unknown): string {
|
|
1522
|
+
return JSON.stringify(canonicalize(value), null, 2);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
function canonicalize(value: unknown): unknown {
|
|
1526
|
+
if (value === null || typeof value !== 'object') return value;
|
|
1527
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
1528
|
+
const rec = value as Record<string, unknown>;
|
|
1529
|
+
const out: Record<string, unknown> = {};
|
|
1530
|
+
for (const key of Object.keys(rec).sort()) {
|
|
1531
|
+
out[key] = canonicalize(rec[key]);
|
|
1532
|
+
}
|
|
1533
|
+
return out;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
async function computeAutoArtifactVersion(outputPath: string): Promise<string> {
|
|
1537
|
+
const today = new Date();
|
|
1538
|
+
const yyyymmdd =
|
|
1539
|
+
today.getUTCFullYear().toString().padStart(4, '0') +
|
|
1540
|
+
(today.getUTCMonth() + 1).toString().padStart(2, '0') +
|
|
1541
|
+
today.getUTCDate().toString().padStart(2, '0');
|
|
1542
|
+
|
|
1543
|
+
let n = 1;
|
|
1544
|
+
try {
|
|
1545
|
+
const existing = await fs.readFile(outputPath, 'utf8');
|
|
1546
|
+
const parsed = JSON.parse(existing) as { artifactVersion?: unknown };
|
|
1547
|
+
if (typeof parsed.artifactVersion === 'string') {
|
|
1548
|
+
const match = /^(\d{8})\.(\d+)$/.exec(parsed.artifactVersion);
|
|
1549
|
+
const dayPart = match?.[1];
|
|
1550
|
+
const nPart = match?.[2];
|
|
1551
|
+
if (dayPart === yyyymmdd && nPart !== undefined) {
|
|
1552
|
+
const parsedN = Number.parseInt(nPart, 10);
|
|
1553
|
+
if (Number.isFinite(parsedN)) n = parsedN + 1;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
} catch {
|
|
1557
|
+
// No prior index.json — start at .1
|
|
1558
|
+
}
|
|
1559
|
+
return `${yyyymmdd}.${n}`;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
// -----------------------------------------------------------------------
|
|
1563
|
+
// Utilities
|
|
1564
|
+
// -----------------------------------------------------------------------
|
|
1565
|
+
|
|
1566
|
+
function isObject(v: unknown): v is Readonly<Record<string, unknown>> {
|
|
1567
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
function sortById<T extends { readonly id: string }>(items: readonly T[]): readonly T[] {
|
|
1571
|
+
return [...items].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function stringifyError(err: unknown): string {
|
|
1575
|
+
if (err instanceof Error) return `${err.name}: ${err.message}`;
|
|
1576
|
+
return String(err);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function serializeCause(err: unknown): unknown {
|
|
1580
|
+
if (err instanceof Error) {
|
|
1581
|
+
return { name: err.name, message: err.message, stack: err.stack };
|
|
1582
|
+
}
|
|
1583
|
+
if (err === undefined) return null;
|
|
1584
|
+
if (
|
|
1585
|
+
err === null ||
|
|
1586
|
+
typeof err === 'string' ||
|
|
1587
|
+
typeof err === 'number' ||
|
|
1588
|
+
typeof err === 'boolean'
|
|
1589
|
+
) {
|
|
1590
|
+
return err;
|
|
1591
|
+
}
|
|
1592
|
+
try {
|
|
1593
|
+
JSON.stringify(err);
|
|
1594
|
+
return err;
|
|
1595
|
+
} catch {
|
|
1596
|
+
return String(err);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
function normalizeModulePath(relPath: string): string {
|
|
1601
|
+
// Forward slashes only, no leading './'
|
|
1602
|
+
let normalized = relPath.replace(/\\/g, '/');
|
|
1603
|
+
if (normalized.startsWith('./')) normalized = normalized.slice(2);
|
|
1604
|
+
return normalized;
|
|
1605
|
+
}
|