@llblab/pi-actors 0.41.0 → 0.42.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/AGENTS.md +13 -8
- package/BACKLOG.md +2 -81
- package/CHANGELOG.md +22 -0
- package/README.md +22 -4
- package/dist/index.js +29 -82
- package/dist/lib/async-runs.d.ts +25 -5
- package/dist/lib/async-runs.js +136 -47
- package/dist/lib/automatic-review-runtime.d.ts +18 -0
- package/dist/lib/automatic-review-runtime.js +96 -0
- package/dist/lib/draft-consolidation-transaction.d.ts +65 -0
- package/dist/lib/draft-consolidation-transaction.js +610 -0
- package/dist/lib/draft-consolidation.d.ts +35 -0
- package/dist/lib/draft-consolidation.js +126 -0
- package/dist/lib/draft-review.d.ts +56 -0
- package/dist/lib/draft-review.js +254 -0
- package/dist/lib/draft-sleep.d.ts +65 -0
- package/dist/lib/draft-sleep.js +468 -0
- package/dist/lib/file-state.d.ts +8 -1
- package/dist/lib/file-state.js +115 -18
- package/dist/lib/inspector-actions.d.ts +16 -0
- package/dist/lib/inspector-actions.js +57 -0
- package/dist/lib/inspector-command.d.ts +7 -0
- package/dist/lib/inspector-command.js +37 -0
- package/dist/lib/inspector-overlay.d.ts +29 -1
- package/dist/lib/inspector-overlay.js +537 -90
- package/dist/lib/inspector.d.ts +9 -0
- package/dist/lib/inspector.js +52 -1
- package/dist/lib/observability.d.ts +39 -3
- package/dist/lib/observability.js +162 -34
- package/dist/lib/paths.d.ts +7 -0
- package/dist/lib/paths.js +28 -0
- package/dist/lib/recipes-discovery.js +32 -24
- package/dist/lib/recipes-usage.d.ts +18 -6
- package/dist/lib/recipes-usage.js +445 -34
- package/dist/lib/review-control.d.ts +14 -0
- package/dist/lib/review-control.js +111 -0
- package/dist/lib/review-diagnostics.d.ts +11 -0
- package/dist/lib/review-diagnostics.js +148 -0
- package/dist/lib/review-projection.d.ts +14 -0
- package/dist/lib/review-projection.js +170 -0
- package/dist/lib/run-ui-runtime.d.ts +18 -0
- package/dist/lib/run-ui-runtime.js +123 -0
- package/dist/lib/runs-artifacts.d.ts +1 -1
- package/dist/lib/runs-artifacts.js +1 -1
- package/dist/lib/runs-control.d.ts +8 -2
- package/dist/lib/runs-control.js +23 -6
- package/dist/lib/runs-identity.d.ts +1 -1
- package/dist/lib/runs-identity.js +1 -1
- package/dist/lib/runs-index.d.ts +11 -2
- package/dist/lib/runs-index.js +46 -23
- package/dist/lib/runs-mailbox.d.ts +1 -1
- package/dist/lib/runs-mailbox.js +1 -1
- package/dist/lib/runs-messages.d.ts +1 -1
- package/dist/lib/runs-messages.js +1 -1
- package/dist/lib/runs-outbox.d.ts +1 -1
- package/dist/lib/runs-outbox.js +1 -1
- package/dist/lib/runs-ownership.d.ts +1 -1
- package/dist/lib/runs-ownership.js +1 -1
- package/dist/lib/runs-parent-teardown.d.ts +51 -0
- package/dist/lib/runs-parent-teardown.js +172 -0
- package/dist/lib/runs-process.d.ts +1 -1
- package/dist/lib/runs-process.js +1 -1
- package/dist/lib/runs-retention.d.ts +1 -1
- package/dist/lib/runs-retention.js +1 -1
- package/dist/lib/runs-start.d.ts +5 -3
- package/dist/lib/runs-start.js +6 -48
- package/dist/lib/runs-status.d.ts +5 -3
- package/dist/lib/runs-status.js +4 -6
- package/dist/lib/runtime.d.ts +7 -1
- package/dist/lib/runtime.js +32 -18
- package/dist/lib/tool-review-lineage-transaction.d.ts +27 -0
- package/dist/lib/tool-review-lineage-transaction.js +597 -0
- package/dist/lib/tool-review-lineage.d.ts +24 -0
- package/dist/lib/tool-review-lineage.js +98 -0
- package/dist/lib/tool-review-scheduler.d.ts +80 -0
- package/dist/lib/tool-review-scheduler.js +494 -0
- package/dist/lib/tool-review-transaction.d.ts +50 -0
- package/dist/lib/tool-review-transaction.js +362 -0
- package/dist/lib/tool-review.d.ts +56 -0
- package/dist/lib/tool-review.js +197 -0
- package/dist/lib/tools-inspect.js +26 -3
- package/dist/lib/tools-local.js +4 -2
- package/dist/lib/tools-message.d.ts +1 -0
- package/dist/lib/tools-message.js +29 -17
- package/dist/lib/tools-response.d.ts +1 -1
- package/dist/lib/tools-response.js +5 -7
- package/dist/lib/tools-spawn.js +4 -1
- package/dist/lib/tools.d.ts +1 -0
- package/dist/lib/tools.js +1 -0
- package/dist/recipes/draft-review.json +24 -0
- package/dist/recipes/tool-review.json +24 -0
- package/dist/scripts/release-gates.mjs +165 -0
- package/dist/skills/actors/SKILL.md +11 -14
- package/dist/skills/swarm/SKILL.md +1 -1
- package/docs/actor-inspector.md +32 -18
- package/docs/async-runs.md +10 -2
- package/docs/recipe-library.md +7 -3
- package/docs/template-recipes.md +4 -11
- package/docs/tool-registry.md +11 -4
- package/index.ts +29 -103
- package/lib/async-runs.ts +218 -62
- package/lib/automatic-review-runtime.ts +135 -0
- package/lib/draft-consolidation-transaction.ts +821 -0
- package/lib/draft-consolidation.ts +181 -0
- package/lib/draft-review.ts +325 -0
- package/lib/draft-sleep.ts +576 -0
- package/lib/file-state.ts +143 -19
- package/lib/inspector-actions.ts +79 -0
- package/lib/inspector-command.ts +54 -0
- package/lib/inspector-overlay.ts +675 -105
- package/lib/inspector.ts +78 -3
- package/lib/observability.ts +219 -40
- package/lib/paths.ts +43 -0
- package/lib/recipes-discovery.ts +34 -26
- package/lib/recipes-usage.ts +569 -40
- package/lib/review-control.ts +137 -0
- package/lib/review-diagnostics.ts +164 -0
- package/lib/review-projection.ts +200 -0
- package/lib/run-ui-runtime.ts +153 -0
- package/lib/runs-artifacts.ts +1 -1
- package/lib/runs-control.ts +49 -5
- package/lib/runs-identity.ts +1 -1
- package/lib/runs-index.ts +57 -21
- package/lib/runs-mailbox.ts +1 -1
- package/lib/runs-messages.ts +1 -1
- package/lib/runs-outbox.ts +1 -1
- package/lib/runs-ownership.ts +1 -1
- package/lib/runs-parent-teardown.ts +257 -0
- package/lib/runs-process.ts +1 -1
- package/lib/runs-retention.ts +1 -1
- package/lib/runs-start.ts +12 -68
- package/lib/runs-status.ts +12 -8
- package/lib/runtime.ts +34 -17
- package/lib/tool-review-lineage-transaction.ts +881 -0
- package/lib/tool-review-lineage.ts +145 -0
- package/lib/tool-review-scheduler.ts +635 -0
- package/lib/tool-review-transaction.ts +563 -0
- package/lib/tool-review.ts +270 -0
- package/lib/tools-inspect.ts +33 -3
- package/lib/tools-local.ts +8 -2
- package/lib/tools-message.ts +45 -30
- package/lib/tools-response.ts +5 -6
- package/lib/tools-spawn.ts +8 -1
- package/lib/tools.ts +5 -0
- package/package.json +3 -2
- package/recipes/draft-review.json +24 -0
- package/recipes/tool-review.json +24 -0
- package/scripts/release-gates.mjs +165 -0
- package/skills/actors/SKILL.md +11 -14
- package/skills/swarm/SKILL.md +1 -1
package/lib/recipes-usage.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Recipe usage metadata
|
|
3
|
-
* Zones: recipe telemetry,
|
|
4
|
-
* Owns
|
|
2
|
+
* Recipe usage and name-based lineage metadata.
|
|
3
|
+
* Zones: recipe lineage, launch telemetry, revision history, migration
|
|
4
|
+
* Owns priority-compatible recipe names and lifetime/revision counters for user recipes.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
-
import { basename, dirname, join } from "node:path";
|
|
8
|
+
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
9
|
+
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
10
10
|
|
|
11
11
|
import { withFileMutationLock, writeJsonAtomic } from "./file-state.ts";
|
|
12
12
|
|
|
13
|
-
interface
|
|
13
|
+
interface RecipeUsageCounters {
|
|
14
14
|
calls?: number;
|
|
15
15
|
direct_calls?: number;
|
|
16
16
|
fingerprint?: string;
|
|
@@ -22,6 +22,47 @@ interface RecipesUsageRecord {
|
|
|
22
22
|
tool_calls?: number;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
interface RecipeUsageIndex {
|
|
26
|
+
paths: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface RecipeLineageEvent {
|
|
30
|
+
at: string;
|
|
31
|
+
from?: string;
|
|
32
|
+
review_epoch?: string;
|
|
33
|
+
to?: string;
|
|
34
|
+
rollback_revision?: number;
|
|
35
|
+
revision?: number;
|
|
36
|
+
type: "created" | "demoted" | "merged" | "promoted" | "renamed" | "replaced" | "reviewed" | "revised" | "rollback" | "split";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface RecipeRevisionRecord {
|
|
40
|
+
fingerprint: string;
|
|
41
|
+
first_seen: string;
|
|
42
|
+
revision: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface RecipeUsageRecord extends RecipeUsageCounters {
|
|
46
|
+
calls: number;
|
|
47
|
+
current_path: string;
|
|
48
|
+
fingerprint: string;
|
|
49
|
+
first_seen: string;
|
|
50
|
+
former_names: string[];
|
|
51
|
+
former_paths: string[];
|
|
52
|
+
lifetime_calls: number;
|
|
53
|
+
lineage_events: RecipeLineageEvent[];
|
|
54
|
+
lineage_name: string;
|
|
55
|
+
review_epochs: string[];
|
|
56
|
+
reviewed_fingerprints: string[];
|
|
57
|
+
rollback_of_revision?: number;
|
|
58
|
+
revision: number;
|
|
59
|
+
revision_calls: number;
|
|
60
|
+
revision_direct_calls: number;
|
|
61
|
+
revision_spawn_calls: number;
|
|
62
|
+
revision_tool_calls: number;
|
|
63
|
+
revisions: RecipeRevisionRecord[];
|
|
64
|
+
}
|
|
65
|
+
|
|
25
66
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
26
67
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
27
68
|
}
|
|
@@ -41,69 +82,557 @@ function stableStringify(value: unknown): string {
|
|
|
41
82
|
.join(",")}}`;
|
|
42
83
|
}
|
|
43
84
|
|
|
44
|
-
function
|
|
45
|
-
const
|
|
46
|
-
|
|
85
|
+
function readRecipeSource(path: string): { fingerprint: string } | undefined {
|
|
86
|
+
const bytes = readFileSync(path);
|
|
87
|
+
if (extname(path).toLowerCase() !== ".json") {
|
|
88
|
+
return {
|
|
89
|
+
fingerprint: createHash("sha256").update(bytes).digest("hex"),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const parsed = JSON.parse(bytes.toString("utf8"));
|
|
93
|
+
if (!isRecord(parsed)) return undefined;
|
|
94
|
+
const { usage: _usage, ...content } = parsed;
|
|
95
|
+
return {
|
|
96
|
+
fingerprint: createHash("sha256")
|
|
97
|
+
.update(stableStringify(content))
|
|
98
|
+
.digest("hex"),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function inferRecipeRoot(path: string): string {
|
|
103
|
+
const parent = dirname(resolve(path));
|
|
104
|
+
return basename(parent) === "drafts" ? dirname(parent) : parent;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function pathKey(path: string, recipeRoot: string): string {
|
|
108
|
+
const relation = relative(resolve(recipeRoot), resolve(path));
|
|
109
|
+
if (!relation || relation.startsWith("..") || resolve(relation) === relation) {
|
|
110
|
+
throw new Error(`Recipe path escapes recipe root: ${path}`);
|
|
111
|
+
}
|
|
112
|
+
return relation.replaceAll("\\", "/");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function recipeName(path: string): string {
|
|
116
|
+
return basename(path).replace(/\.(?:json|md)$/u, "");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function lineageFilename(lineageName: string): string {
|
|
120
|
+
if (!lineageName || lineageName === "." || lineageName === "..") {
|
|
121
|
+
throw new Error("Invalid recipe lineage name.");
|
|
122
|
+
}
|
|
123
|
+
return `${encodeURIComponent(lineageName)}.json`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function appendBounded(values: string[], value: string, limit = 64): string[] {
|
|
127
|
+
const next = [...values.filter((item) => item !== value), value];
|
|
128
|
+
return next.slice(Math.max(0, next.length - limit));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function appendEvent(
|
|
132
|
+
events: RecipeLineageEvent[],
|
|
133
|
+
event: RecipeLineageEvent,
|
|
134
|
+
): RecipeLineageEvent[] {
|
|
135
|
+
return [...events, event].slice(-128);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function appendRevision(
|
|
139
|
+
revisions: RecipeRevisionRecord[],
|
|
140
|
+
revision: RecipeRevisionRecord,
|
|
141
|
+
): RecipeRevisionRecord[] {
|
|
142
|
+
return [...revisions, revision].slice(-128);
|
|
47
143
|
}
|
|
48
144
|
|
|
49
145
|
export type RecipeLaunchKind = "direct" | "spawn" | "tool";
|
|
50
146
|
|
|
51
|
-
export function
|
|
52
|
-
return join(
|
|
147
|
+
export function getRecipeUsageIndexPath(recipeRoot: string): string {
|
|
148
|
+
return join(resolve(recipeRoot), ".usage", "index.json");
|
|
53
149
|
}
|
|
54
150
|
|
|
55
|
-
export function
|
|
151
|
+
export function getRecipeUsageLedgerPath(
|
|
152
|
+
lineageName: string,
|
|
153
|
+
recipeRoot: string,
|
|
154
|
+
): string {
|
|
155
|
+
return join(
|
|
156
|
+
resolve(recipeRoot),
|
|
157
|
+
".usage",
|
|
158
|
+
"recipes",
|
|
159
|
+
lineageFilename(lineageName),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const RECIPE_REVISION_SNAPSHOT_LIMIT = 32;
|
|
164
|
+
|
|
165
|
+
export function getRecipeRevisionSnapshotPath(
|
|
166
|
+
lineageName: string,
|
|
167
|
+
revision: number,
|
|
168
|
+
recipeRoot: string,
|
|
169
|
+
): string {
|
|
170
|
+
if (!Number.isInteger(revision) || revision <= 0) {
|
|
171
|
+
throw new Error("Invalid recipe revision snapshot number.");
|
|
172
|
+
}
|
|
173
|
+
const slot = ((revision - 1) % RECIPE_REVISION_SNAPSHOT_LIMIT) + 1;
|
|
174
|
+
return join(
|
|
175
|
+
resolve(recipeRoot),
|
|
176
|
+
".usage",
|
|
177
|
+
"revisions",
|
|
178
|
+
encodeURIComponent(lineageName),
|
|
179
|
+
`${slot}.json`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function readJsonRecord(path: string): Record<string, unknown> | undefined {
|
|
56
184
|
try {
|
|
57
|
-
const value = JSON.parse(readFileSync(
|
|
185
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
58
186
|
return isRecord(value) ? value : undefined;
|
|
59
187
|
} catch {
|
|
60
188
|
return undefined;
|
|
61
189
|
}
|
|
62
190
|
}
|
|
63
191
|
|
|
192
|
+
function readIndex(recipeRoot: string): RecipeUsageIndex {
|
|
193
|
+
const value = readJsonRecord(getRecipeUsageIndexPath(recipeRoot));
|
|
194
|
+
const paths = value?.paths;
|
|
195
|
+
return {
|
|
196
|
+
paths: isRecord(paths)
|
|
197
|
+
? Object.fromEntries(
|
|
198
|
+
Object.entries(paths).filter(
|
|
199
|
+
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
: {},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function normalizeStoredLedger(
|
|
207
|
+
value: Record<string, unknown>,
|
|
208
|
+
lineageName: string,
|
|
209
|
+
): RecipeUsageRecord | undefined {
|
|
210
|
+
return value.lineage_name === lineageName
|
|
211
|
+
? value as unknown as RecipeUsageRecord
|
|
212
|
+
: undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function readLedger(
|
|
216
|
+
lineageName: string,
|
|
217
|
+
recipeRoot: string,
|
|
218
|
+
): RecipeUsageRecord | undefined {
|
|
219
|
+
const value = readJsonRecord(getRecipeUsageLedgerPath(lineageName, recipeRoot));
|
|
220
|
+
return value ? normalizeStoredLedger(value, lineageName) : undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function readIndexedLedger(
|
|
224
|
+
indexedName: string,
|
|
225
|
+
recipeRoot: string,
|
|
226
|
+
): { record: RecipeUsageRecord } | undefined {
|
|
227
|
+
const current = readLedger(indexedName, recipeRoot);
|
|
228
|
+
return current ? { record: current } : undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function findRelocatedRecipe(
|
|
232
|
+
index: RecipeUsageIndex,
|
|
233
|
+
fingerprint: string,
|
|
234
|
+
recipeRoot: string,
|
|
235
|
+
): { indexedName: string; path: string; record: RecipeUsageRecord } | undefined {
|
|
236
|
+
const candidates = Object.entries(index.paths).flatMap(([oldPath, indexedName]) => {
|
|
237
|
+
if (existsSync(join(recipeRoot, oldPath))) return [];
|
|
238
|
+
const found = readIndexedLedger(indexedName, recipeRoot);
|
|
239
|
+
return found?.record.fingerprint === fingerprint
|
|
240
|
+
? [{ indexedName, path: oldPath, record: found.record }]
|
|
241
|
+
: [];
|
|
242
|
+
});
|
|
243
|
+
return candidates.length === 1 ? candidates[0] : undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
64
246
|
function launchCounterKey(
|
|
65
247
|
kind: RecipeLaunchKind,
|
|
66
248
|
): "direct_calls" | "spawn_calls" | "tool_calls" {
|
|
67
249
|
return `${kind}_calls` as "direct_calls" | "spawn_calls" | "tool_calls";
|
|
68
250
|
}
|
|
69
251
|
|
|
70
|
-
|
|
252
|
+
function revisionCounterKey(
|
|
253
|
+
kind: RecipeLaunchKind,
|
|
254
|
+
): "revision_direct_calls" | "revision_spawn_calls" | "revision_tool_calls" {
|
|
255
|
+
return `revision_${kind}_calls` as
|
|
256
|
+
| "revision_direct_calls"
|
|
257
|
+
| "revision_spawn_calls"
|
|
258
|
+
| "revision_tool_calls";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function readRecipeUsage(
|
|
262
|
+
path: string,
|
|
263
|
+
recipeRoot = inferRecipeRoot(path),
|
|
264
|
+
): Record<string, unknown> | undefined {
|
|
265
|
+
try {
|
|
266
|
+
const key = pathKey(path, recipeRoot);
|
|
267
|
+
const indexedName = readIndex(recipeRoot).paths[key];
|
|
268
|
+
if (indexedName) {
|
|
269
|
+
return readIndexedLedger(indexedName, recipeRoot)?.record as unknown as
|
|
270
|
+
| Record<string, unknown>
|
|
271
|
+
| undefined;
|
|
272
|
+
}
|
|
273
|
+
return undefined;
|
|
274
|
+
} catch {
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function ensureRecipeLineage(
|
|
71
280
|
path: string,
|
|
72
281
|
now = new Date(),
|
|
73
|
-
|
|
282
|
+
recipeRoot = inferRecipeRoot(path),
|
|
74
283
|
): boolean {
|
|
75
284
|
if (!existsSync(path)) return false;
|
|
76
|
-
const
|
|
77
|
-
return withFileMutationLock(
|
|
285
|
+
const indexPath = getRecipeUsageIndexPath(recipeRoot);
|
|
286
|
+
return withFileMutationLock(indexPath, () => {
|
|
78
287
|
try {
|
|
79
|
-
const
|
|
80
|
-
if (!
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
288
|
+
const source = readRecipeSource(path);
|
|
289
|
+
if (!source) return false;
|
|
290
|
+
const key = pathKey(path, recipeRoot);
|
|
291
|
+
const name = recipeName(path);
|
|
292
|
+
const index = readIndex(recipeRoot);
|
|
293
|
+
const indexedName = index.paths[key];
|
|
294
|
+
if (indexedName) {
|
|
295
|
+
const found = readIndexedLedger(indexedName, recipeRoot);
|
|
296
|
+
if (!found || indexedName !== name) return false;
|
|
297
|
+
if (found.record.fingerprint === source.fingerprint) return true;
|
|
298
|
+
const ledgerPath = getRecipeUsageLedgerPath(name, recipeRoot);
|
|
299
|
+
return withFileMutationLock(ledgerPath, () => {
|
|
300
|
+
const current = readLedger(name, recipeRoot);
|
|
301
|
+
if (!current) return false;
|
|
302
|
+
if (current.fingerprint === source.fingerprint) return true;
|
|
303
|
+
const nowIso = now.toISOString();
|
|
304
|
+
const revision = current.revision + 1;
|
|
305
|
+
writeJsonAtomic(ledgerPath, {
|
|
306
|
+
...current,
|
|
307
|
+
fingerprint: source.fingerprint,
|
|
308
|
+
lineage_events: appendEvent(current.lineage_events, {
|
|
309
|
+
at: nowIso,
|
|
310
|
+
type: "revised",
|
|
311
|
+
}),
|
|
312
|
+
reset_at: nowIso,
|
|
313
|
+
reset_reason: "recipe content fingerprint changed",
|
|
314
|
+
revision,
|
|
315
|
+
revision_calls: 0,
|
|
316
|
+
revision_direct_calls: 0,
|
|
317
|
+
revision_spawn_calls: 0,
|
|
318
|
+
revision_tool_calls: 0,
|
|
319
|
+
revisions: appendRevision(current.revisions, {
|
|
320
|
+
fingerprint: source.fingerprint,
|
|
321
|
+
first_seen: nowIso,
|
|
322
|
+
revision,
|
|
323
|
+
}),
|
|
324
|
+
});
|
|
325
|
+
return true;
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
if (readLedger(name, recipeRoot)) return false;
|
|
88
329
|
const nowIso = now.toISOString();
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
:
|
|
330
|
+
const ledgerPath = getRecipeUsageLedgerPath(name, recipeRoot);
|
|
331
|
+
return withFileMutationLock(ledgerPath, () => {
|
|
332
|
+
if (readLedger(name, recipeRoot)) return false;
|
|
333
|
+
writeJsonAtomic(ledgerPath, {
|
|
334
|
+
calls: 0,
|
|
335
|
+
current_path: key,
|
|
336
|
+
fingerprint: source.fingerprint,
|
|
337
|
+
first_seen: nowIso,
|
|
338
|
+
former_names: [],
|
|
339
|
+
former_paths: [],
|
|
340
|
+
lifetime_calls: 0,
|
|
341
|
+
lineage_events: [{ at: nowIso, type: "created" }],
|
|
342
|
+
lineage_name: name,
|
|
343
|
+
review_epochs: [],
|
|
344
|
+
reviewed_fingerprints: [],
|
|
345
|
+
revision: 1,
|
|
346
|
+
revision_calls: 0,
|
|
347
|
+
revision_direct_calls: 0,
|
|
348
|
+
revision_spawn_calls: 0,
|
|
349
|
+
revision_tool_calls: 0,
|
|
350
|
+
revisions: [{ fingerprint: source.fingerprint, first_seen: nowIso, revision: 1 }],
|
|
351
|
+
} satisfies RecipeUsageRecord);
|
|
352
|
+
index.paths[key] = name;
|
|
353
|
+
writeJsonAtomic(indexPath, index);
|
|
354
|
+
return true;
|
|
355
|
+
});
|
|
356
|
+
} catch {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function isCurrentRecipeRevisionReviewed(
|
|
363
|
+
path: string,
|
|
364
|
+
recipeRoot = inferRecipeRoot(path),
|
|
365
|
+
): boolean {
|
|
366
|
+
const usage = readRecipeUsage(path, recipeRoot);
|
|
367
|
+
return Boolean(
|
|
368
|
+
usage &&
|
|
369
|
+
typeof usage.fingerprint === "string" &&
|
|
370
|
+
Array.isArray(usage.reviewed_fingerprints) &&
|
|
371
|
+
usage.reviewed_fingerprints.includes(usage.fingerprint),
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export interface RecipeLaunchRecordOptions {
|
|
376
|
+
onMutationLockContention?(): void;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function recordRecipeLaunch(
|
|
380
|
+
path: string,
|
|
381
|
+
now = new Date(),
|
|
382
|
+
kind: RecipeLaunchKind = "direct",
|
|
383
|
+
recipeRoot = inferRecipeRoot(path),
|
|
384
|
+
options: RecipeLaunchRecordOptions = {},
|
|
385
|
+
): boolean {
|
|
386
|
+
return withFileMutationLock(recipeRoot, () => {
|
|
387
|
+
if (!existsSync(path)) return false;
|
|
388
|
+
const indexPath = getRecipeUsageIndexPath(recipeRoot);
|
|
389
|
+
return withFileMutationLock(indexPath, () => {
|
|
390
|
+
try {
|
|
391
|
+
const source = readRecipeSource(path);
|
|
392
|
+
if (!source) return false;
|
|
393
|
+
const { fingerprint } = source;
|
|
394
|
+
const key = pathKey(path, recipeRoot);
|
|
395
|
+
const desiredName = recipeName(path);
|
|
396
|
+
const index = readIndex(recipeRoot);
|
|
397
|
+
const relocated = index.paths[key]
|
|
398
|
+
? undefined
|
|
399
|
+
: findRelocatedRecipe(index, fingerprint, recipeRoot);
|
|
400
|
+
const indexedName = index.paths[key] ?? relocated?.indexedName;
|
|
401
|
+
const found = indexedName
|
|
402
|
+
? readIndexedLedger(indexedName, recipeRoot)
|
|
403
|
+
: undefined;
|
|
404
|
+
const existingAtDesired = readLedger(desiredName, recipeRoot);
|
|
405
|
+
if (!found && existingAtDesired) return false;
|
|
406
|
+
const stored = found?.record;
|
|
407
|
+
const ledgerPath = getRecipeUsageLedgerPath(desiredName, recipeRoot);
|
|
408
|
+
return withFileMutationLock(ledgerPath, () => {
|
|
409
|
+
const nowIso = now.toISOString();
|
|
410
|
+
const changed = Boolean(stored && stored.fingerprint !== fingerprint);
|
|
411
|
+
const renamed = Boolean(stored && stored.lineage_name !== desiredName);
|
|
412
|
+
const lifetimeCalls = normalizeCalls(
|
|
413
|
+
stored?.lifetime_calls ?? stored?.calls,
|
|
414
|
+
) + 1;
|
|
415
|
+
const counterKey = launchCounterKey(kind);
|
|
416
|
+
const revisionKey = revisionCounterKey(kind);
|
|
417
|
+
const revision = (stored?.revision ?? 1) + (changed ? 1 : 0);
|
|
418
|
+
let events = stored?.lineage_events ?? [];
|
|
419
|
+
if (renamed) {
|
|
420
|
+
events = appendEvent(events, {
|
|
421
|
+
at: nowIso,
|
|
422
|
+
from: stored!.lineage_name,
|
|
423
|
+
to: desiredName,
|
|
424
|
+
type: "renamed",
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
if (changed) events = appendEvent(events, { at: nowIso, type: "revised" });
|
|
428
|
+
if (events.length === 0) events = [{ at: nowIso, type: "created" }];
|
|
429
|
+
const existingRevisions = stored?.revisions ?? [];
|
|
430
|
+
const record: RecipeUsageRecord = {
|
|
431
|
+
...stored,
|
|
432
|
+
calls: lifetimeCalls,
|
|
433
|
+
current_path: key,
|
|
434
|
+
fingerprint,
|
|
435
|
+
first_seen: stored?.first_seen ?? nowIso,
|
|
436
|
+
former_names: renamed
|
|
437
|
+
? appendBounded(stored?.former_names ?? [], stored!.lineage_name)
|
|
438
|
+
: relocated
|
|
439
|
+
? appendBounded(stored?.former_names ?? [], recipeName(relocated.path))
|
|
440
|
+
: stored?.former_names ?? [],
|
|
441
|
+
former_paths: relocated
|
|
442
|
+
? appendBounded(stored?.former_paths ?? [], relocated.path)
|
|
443
|
+
: stored?.former_paths ?? [],
|
|
444
|
+
lifetime_calls: lifetimeCalls,
|
|
445
|
+
lineage_events: events,
|
|
446
|
+
lineage_name: desiredName,
|
|
447
|
+
review_epochs: stored?.review_epochs ?? [],
|
|
448
|
+
reviewed_fingerprints: stored?.reviewed_fingerprints ?? [],
|
|
449
|
+
revision,
|
|
450
|
+
revision_calls: (changed ? 0 : normalizeCalls(stored?.revision_calls)) + 1,
|
|
451
|
+
revision_direct_calls: changed ? 0 : normalizeCalls(stored?.revision_direct_calls),
|
|
452
|
+
revision_spawn_calls: changed ? 0 : normalizeCalls(stored?.revision_spawn_calls),
|
|
453
|
+
revision_tool_calls: changed ? 0 : normalizeCalls(stored?.revision_tool_calls),
|
|
454
|
+
revisions: changed
|
|
455
|
+
? appendRevision(existingRevisions, { fingerprint, first_seen: nowIso, revision })
|
|
456
|
+
: existingRevisions.length > 0
|
|
457
|
+
? existingRevisions
|
|
458
|
+
: [{ fingerprint, first_seen: nowIso, revision }],
|
|
459
|
+
[counterKey]: normalizeCalls(stored?.[counterKey]) + 1,
|
|
460
|
+
[revisionKey]: (changed ? 0 : normalizeCalls(stored?.[revisionKey])) + 1,
|
|
461
|
+
last_called: nowIso,
|
|
462
|
+
launch_kind: kind,
|
|
463
|
+
...(changed
|
|
464
|
+
? { reset_at: nowIso, reset_reason: "recipe content fingerprint changed" }
|
|
465
|
+
: {}),
|
|
466
|
+
};
|
|
467
|
+
writeJsonAtomic(ledgerPath, record);
|
|
468
|
+
if (indexedName && indexedName !== desiredName) {
|
|
469
|
+
const oldPath = getRecipeUsageLedgerPath(indexedName, recipeRoot);
|
|
470
|
+
if (existsSync(oldPath)) unlinkSync(oldPath);
|
|
471
|
+
}
|
|
472
|
+
if (relocated) delete index.paths[relocated.path];
|
|
473
|
+
index.paths[key] = desiredName;
|
|
474
|
+
writeJsonAtomic(indexPath, index);
|
|
475
|
+
return true;
|
|
476
|
+
});
|
|
477
|
+
} catch {
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
}, { onContention: options.onMutationLockContention });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export function recordRecipeReview(
|
|
485
|
+
path: string,
|
|
486
|
+
reviewEpoch: string,
|
|
487
|
+
now = new Date(),
|
|
488
|
+
recipeRoot = inferRecipeRoot(path),
|
|
489
|
+
): boolean {
|
|
490
|
+
if (!reviewEpoch.trim()) return false;
|
|
491
|
+
const indexPath = getRecipeUsageIndexPath(recipeRoot);
|
|
492
|
+
return withFileMutationLock(indexPath, () => {
|
|
493
|
+
try {
|
|
494
|
+
const key = pathKey(path, recipeRoot);
|
|
495
|
+
const lineageName = readIndex(recipeRoot).paths[key];
|
|
496
|
+
if (!lineageName) return false;
|
|
497
|
+
const ledgerPath = getRecipeUsageLedgerPath(lineageName, recipeRoot);
|
|
498
|
+
return withFileMutationLock(ledgerPath, () => {
|
|
499
|
+
const ledger = readLedger(lineageName, recipeRoot);
|
|
500
|
+
if (!ledger) return false;
|
|
501
|
+
if (ledger.review_epochs.includes(reviewEpoch)) return true;
|
|
502
|
+
writeJsonAtomic(ledgerPath, {
|
|
503
|
+
...ledger,
|
|
504
|
+
lineage_events: appendEvent(ledger.lineage_events ?? [], {
|
|
505
|
+
at: now.toISOString(),
|
|
506
|
+
review_epoch: reviewEpoch,
|
|
507
|
+
type: "reviewed",
|
|
508
|
+
}),
|
|
509
|
+
review_epochs: appendBounded(ledger.review_epochs, reviewEpoch, 128),
|
|
510
|
+
reviewed_fingerprints: appendBounded(
|
|
511
|
+
ledger.reviewed_fingerprints ?? [],
|
|
512
|
+
ledger.fingerprint,
|
|
513
|
+
128,
|
|
514
|
+
),
|
|
515
|
+
});
|
|
516
|
+
return true;
|
|
103
517
|
});
|
|
518
|
+
} catch {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function recordRecipeRollback(
|
|
525
|
+
path: string,
|
|
526
|
+
rollbackRevision: number,
|
|
527
|
+
now = new Date(),
|
|
528
|
+
recipeRoot = inferRecipeRoot(path),
|
|
529
|
+
): boolean {
|
|
530
|
+
if (!Number.isInteger(rollbackRevision) || rollbackRevision <= 0) return false;
|
|
531
|
+
const indexPath = getRecipeUsageIndexPath(recipeRoot);
|
|
532
|
+
return withFileMutationLock(indexPath, () => {
|
|
533
|
+
try {
|
|
534
|
+
const key = pathKey(path, recipeRoot);
|
|
535
|
+
const lineageName = readIndex(recipeRoot).paths[key];
|
|
536
|
+
if (!lineageName) return false;
|
|
537
|
+
const ledgerPath = getRecipeUsageLedgerPath(lineageName, recipeRoot);
|
|
538
|
+
return withFileMutationLock(ledgerPath, () => {
|
|
539
|
+
const ledger = readLedger(lineageName, recipeRoot);
|
|
540
|
+
if (!ledger) return false;
|
|
541
|
+
if (ledger.rollback_of_revision === rollbackRevision) return true;
|
|
542
|
+
writeJsonAtomic(ledgerPath, {
|
|
543
|
+
...ledger,
|
|
544
|
+
lineage_events: appendEvent(ledger.lineage_events ?? [], {
|
|
545
|
+
at: now.toISOString(),
|
|
546
|
+
revision: ledger.revision,
|
|
547
|
+
rollback_revision: rollbackRevision,
|
|
548
|
+
type: "rollback",
|
|
549
|
+
}),
|
|
550
|
+
rollback_of_revision: rollbackRevision,
|
|
551
|
+
});
|
|
552
|
+
return true;
|
|
553
|
+
});
|
|
554
|
+
} catch {
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export function retireRecipeUsage(
|
|
561
|
+
path: string,
|
|
562
|
+
recipeRoot = inferRecipeRoot(path),
|
|
563
|
+
): boolean {
|
|
564
|
+
const indexPath = getRecipeUsageIndexPath(recipeRoot);
|
|
565
|
+
return withFileMutationLock(indexPath, () => {
|
|
566
|
+
try {
|
|
567
|
+
const key = pathKey(path, recipeRoot);
|
|
568
|
+
const index = readIndex(recipeRoot);
|
|
569
|
+
const lineageName = index.paths[key];
|
|
570
|
+
if (!lineageName) return true;
|
|
571
|
+
delete index.paths[key];
|
|
572
|
+
writeJsonAtomic(indexPath, index);
|
|
573
|
+
const ledgerPath = getRecipeUsageLedgerPath(lineageName, recipeRoot);
|
|
574
|
+
if (existsSync(ledgerPath)) unlinkSync(ledgerPath);
|
|
104
575
|
return true;
|
|
105
576
|
} catch {
|
|
106
577
|
return false;
|
|
107
578
|
}
|
|
108
579
|
});
|
|
109
580
|
}
|
|
581
|
+
|
|
582
|
+
export function moveRecipeUsage(
|
|
583
|
+
fromPath: string,
|
|
584
|
+
toPath: string,
|
|
585
|
+
recipeRoot = inferRecipeRoot(fromPath),
|
|
586
|
+
): boolean {
|
|
587
|
+
const indexPath = getRecipeUsageIndexPath(recipeRoot);
|
|
588
|
+
return withFileMutationLock(indexPath, () => {
|
|
589
|
+
try {
|
|
590
|
+
const fromKey = pathKey(fromPath, recipeRoot);
|
|
591
|
+
const toKey = pathKey(toPath, recipeRoot);
|
|
592
|
+
const index = readIndex(recipeRoot);
|
|
593
|
+
const oldName = index.paths[fromKey];
|
|
594
|
+
const newName = recipeName(toPath);
|
|
595
|
+
if (!oldName) {
|
|
596
|
+
return index.paths[toKey] === newName && Boolean(readLedger(newName, recipeRoot));
|
|
597
|
+
}
|
|
598
|
+
if (index.paths[toKey]) return false;
|
|
599
|
+
const found = readIndexedLedger(oldName, recipeRoot);
|
|
600
|
+
if (!found || (oldName !== newName && readLedger(newName, recipeRoot))) return false;
|
|
601
|
+
const oldLedgerPath = getRecipeUsageLedgerPath(oldName, recipeRoot);
|
|
602
|
+
const newLedgerPath = getRecipeUsageLedgerPath(newName, recipeRoot);
|
|
603
|
+
return withFileMutationLock(newLedgerPath, () => {
|
|
604
|
+
const fromDraft = fromKey.startsWith("drafts/");
|
|
605
|
+
const toDraft = toKey.startsWith("drafts/");
|
|
606
|
+
const transition: RecipeLineageEvent["type"] = fromDraft && !toDraft
|
|
607
|
+
? "promoted"
|
|
608
|
+
: !fromDraft && toDraft
|
|
609
|
+
? "demoted"
|
|
610
|
+
: "renamed";
|
|
611
|
+
writeJsonAtomic(newLedgerPath, {
|
|
612
|
+
...found.record,
|
|
613
|
+
current_path: toKey,
|
|
614
|
+
former_names: oldName !== newName
|
|
615
|
+
? appendBounded(found.record.former_names, oldName)
|
|
616
|
+
: found.record.former_names,
|
|
617
|
+
former_paths: appendBounded(found.record.former_paths, fromKey),
|
|
618
|
+
lineage_events: appendEvent(found.record.lineage_events ?? [], {
|
|
619
|
+
at: new Date().toISOString(),
|
|
620
|
+
from: oldName,
|
|
621
|
+
to: newName,
|
|
622
|
+
type: transition,
|
|
623
|
+
}),
|
|
624
|
+
lineage_name: newName,
|
|
625
|
+
});
|
|
626
|
+
index.paths[toKey] = newName;
|
|
627
|
+
delete index.paths[fromKey];
|
|
628
|
+
writeJsonAtomic(indexPath, index);
|
|
629
|
+
if (oldLedgerPath !== newLedgerPath && existsSync(oldLedgerPath)) {
|
|
630
|
+
unlinkSync(oldLedgerPath);
|
|
631
|
+
}
|
|
632
|
+
return true;
|
|
633
|
+
});
|
|
634
|
+
} catch {
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
}
|