@afokapu/atdd-flow 0.3.57 → 0.3.59
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/package.json +1 -1
- package/src/core.ts +52 -4
- package/src/pi-runtime-launch.ts +11 -2
package/package.json
CHANGED
package/src/core.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import {
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
import { Document, Scalar, parse as parseYaml, stringify as stringifyYaml, visit } from "yaml";
|
|
6
7
|
|
|
7
8
|
export type Role = { address: string; branch: string; agent?: string; purpose?: string; worktree?: string; base?: string };
|
|
8
9
|
export type Group = { role?: string; members?: string[] };
|
|
@@ -24,6 +25,8 @@ export type Desk = {
|
|
|
24
25
|
/** Named commands that model portfolio entries may resolve through. */
|
|
25
26
|
executables?: Record<string, string>;
|
|
26
27
|
aliases?: Record<string, string>;
|
|
28
|
+
/** Model id used for a role when launch model selection falls back; unlisted roles use the strongest candidate. */
|
|
29
|
+
fallback_model?: Record<string, string>;
|
|
27
30
|
};
|
|
28
31
|
export type ModelCandidate = {
|
|
29
32
|
id: string;
|
|
@@ -133,9 +136,48 @@ export const required = <T>(value: T | undefined, label: string) => {
|
|
|
133
136
|
if (value === undefined || value === "") throw new Error(`Missing ${label}.`);
|
|
134
137
|
return value;
|
|
135
138
|
};
|
|
139
|
+
const printOptions = { collectionStyle: "flow", flowCollectionPadding: false } as const;
|
|
140
|
+
|
|
141
|
+
// What a faithful YAML round trip must return: JSON semantics, but never silently lossy.
|
|
142
|
+
function expectedRoundTrip(value: unknown) {
|
|
143
|
+
return JSON.parse(JSON.stringify(value, (_key, entry) => {
|
|
144
|
+
if (typeof entry === "function" || typeof entry === "symbol" || typeof entry === "bigint") throw new Error("Value is not representable in YAML.");
|
|
145
|
+
if (typeof entry === "number" && !Number.isFinite(entry)) throw new Error("Value is not representable in YAML.");
|
|
146
|
+
return entry;
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Tier 2 keeps multi-line text readable as block literals; tier 3 quotes every string.
|
|
151
|
+
const printTiers: Array<(expected: unknown) => string> = [
|
|
152
|
+
(expected) => stringifyYaml(expected, printOptions),
|
|
153
|
+
(expected) => {
|
|
154
|
+
const document = new Document(expected, { ...printOptions, lineWidth: 0 });
|
|
155
|
+
visit(document, { Scalar: (_key, node) => { if (typeof node.value === "string" && node.value.includes("\n")) node.type = Scalar.BLOCK_LITERAL; } });
|
|
156
|
+
return document.toString();
|
|
157
|
+
},
|
|
158
|
+
(expected) => stringifyYaml(expected, { ...printOptions, lineWidth: 0, defaultStringType: "QUOTE_DOUBLE", defaultKeyType: "PLAIN" }),
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
// A parse error and a value mismatch are equally unsafe to publish, so both count as "does not round-trip".
|
|
162
|
+
// Strict deep equality also catches silent coercions such as the string "null" becoming null.
|
|
163
|
+
function roundTrips(text: string, expected: unknown) {
|
|
164
|
+
try { return isDeepStrictEqual(parseYaml(text), expected); }
|
|
165
|
+
catch { return false; }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Serializes and proves the text parses back to the same value; the first tier that does wins, otherwise fail closed. */
|
|
169
|
+
function printVerified(value: unknown) {
|
|
170
|
+
const expected = expectedRoundTrip(value);
|
|
171
|
+
for (const tier of printTiers) {
|
|
172
|
+
const text = tier(expected);
|
|
173
|
+
if (roundTrips(text, expected)) return text;
|
|
174
|
+
}
|
|
175
|
+
throw new Error("Refusing to write YAML that does not parse back to the original value.");
|
|
176
|
+
}
|
|
177
|
+
|
|
136
178
|
export const yaml = {
|
|
137
179
|
parse: <T>(text: string) => parseYaml(text) as T,
|
|
138
|
-
print: (value: unknown) =>
|
|
180
|
+
print: (value: unknown) => printVerified(value),
|
|
139
181
|
};
|
|
140
182
|
|
|
141
183
|
export function addressParts(address: string) {
|
|
@@ -176,7 +218,13 @@ export const paths = (root: string) => ({
|
|
|
176
218
|
export async function atomicYaml(file: string, value: unknown) {
|
|
177
219
|
await mkdir(dirname(file), { recursive: true });
|
|
178
220
|
const temp = join(dirname(file), `.${basename(file)}.${randomUUID()}.tmp`);
|
|
179
|
-
|
|
221
|
+
const text = yaml.print(value);
|
|
222
|
+
await writeFile(temp, text, "utf8");
|
|
223
|
+
// yaml.print already proved this text round-trips; compare the bytes on disk to it before the rename publishes them.
|
|
224
|
+
if ((await readFile(temp, "utf8")) !== text) {
|
|
225
|
+
await rm(temp, { force: true });
|
|
226
|
+
throw new Error(`Refusing to publish ${file}: written YAML does not round-trip.`);
|
|
227
|
+
}
|
|
180
228
|
await rename(temp, file);
|
|
181
229
|
}
|
|
182
230
|
|
package/src/pi-runtime-launch.ts
CHANGED
|
@@ -108,6 +108,15 @@ async function launchEligibleTasks(root: string, record: Seat, address: string)
|
|
|
108
108
|
return eligible.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/** Fallback is the role's configured model when set (failing closed if unavailable), else the strongest candidate. */
|
|
112
|
+
function fallbackCandidate(config: Desk, role: string, candidates: ModelCandidate[], reason: string) {
|
|
113
|
+
const configured = config.fallback_model?.[role];
|
|
114
|
+
if (configured === undefined) return { candidate: candidates[0]! };
|
|
115
|
+
const candidate = candidates.find((entry) => entry.id === configured);
|
|
116
|
+
if (!candidate) throw new Error(`fallback_model.${role} names "${configured}", which is not an enabled model with an available executable in models.yaml; refusing launch.`);
|
|
117
|
+
return { candidate, appliedFallback: { role, model: candidate.id, reason } };
|
|
118
|
+
}
|
|
119
|
+
|
|
111
120
|
async function chooseLaunchModel(root: string, config: Desk, record: Seat, portfolio: ModelPortfolio, select: (input: ModelSelectionInput) => Promise<ModelSelectionResponse> = selectModel) {
|
|
112
121
|
const candidates = availableModelCandidates(config, portfolio);
|
|
113
122
|
if (!candidates.length) throw new Error("No enabled model in models.yaml has an available executable.");
|
|
@@ -128,7 +137,7 @@ async function chooseLaunchModel(root: string, config: Desk, record: Seat, portf
|
|
|
128
137
|
const reason = !selection.available ? selection.reason : selection.confidence < 0.75
|
|
129
138
|
? `Jev model selection confidence is low (${selection.confidence}).`
|
|
130
139
|
: `Jev selected a model outside the available portfolio: ${selection.selected_model}.`;
|
|
131
|
-
return {
|
|
140
|
+
return { selection, fallback: reason, ...fallbackCandidate(config, record.role, candidates, reason) };
|
|
132
141
|
}
|
|
133
142
|
return { candidate: required(candidates.find((entry) => entry.id === selection.selected_model), `selected model ${selection.selected_model}`), selection };
|
|
134
143
|
}
|
|
@@ -390,7 +399,7 @@ async function verifyRuntime(launch: PreparedLaunch, pane: string, priorPath?: s
|
|
|
390
399
|
|
|
391
400
|
function receiptSelection(launch: PreparedLaunch) {
|
|
392
401
|
const { selection } = launch;
|
|
393
|
-
if (selection.fallback) return { result: "fallback", reason: selection.fallback };
|
|
402
|
+
if (selection.fallback) return { result: "fallback", reason: selection.fallback, ...(selection.appliedFallback ? { applied_fallback: selection.appliedFallback } : {}) };
|
|
394
403
|
return { result: "selected", confidence: selection.selection.confidence,
|
|
395
404
|
...(selection.selection.available && selection.selection.model ? { model: selection.selection.model } : {}) };
|
|
396
405
|
}
|