@marcoscale98/pi-extensible-workflows 5.14.1-fork.1
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/CHANGELOG.md +536 -0
- package/README.md +64 -0
- package/dist/src/agent-execution.d.ts +338 -0
- package/dist/src/agent-execution.js +1633 -0
- package/dist/src/background-widget.d.ts +74 -0
- package/dist/src/background-widget.js +1340 -0
- package/dist/src/budget.d.ts +38 -0
- package/dist/src/budget.js +188 -0
- package/dist/src/changelog.d.ts +6 -0
- package/dist/src/changelog.js +112 -0
- package/dist/src/decoders.d.ts +96 -0
- package/dist/src/decoders.js +629 -0
- package/dist/src/execution.d.ts +19 -0
- package/dist/src/execution.js +749 -0
- package/dist/src/herdr.d.ts +53 -0
- package/dist/src/herdr.js +270 -0
- package/dist/src/host-delivery.d.ts +95 -0
- package/dist/src/host-delivery.js +376 -0
- package/dist/src/host-navigator.d.ts +83 -0
- package/dist/src/host-navigator.js +1087 -0
- package/dist/src/host-phases.d.ts +89 -0
- package/dist/src/host-phases.js +320 -0
- package/dist/src/host-recovery.d.ts +141 -0
- package/dist/src/host-recovery.js +356 -0
- package/dist/src/host-runtime.d.ts +78 -0
- package/dist/src/host-runtime.js +378 -0
- package/dist/src/host-view.d.ts +116 -0
- package/dist/src/host-view.js +868 -0
- package/dist/src/host.d.ts +40 -0
- package/dist/src/host.js +1641 -0
- package/dist/src/index.d.ts +29 -0
- package/dist/src/index.js +13725 -0
- package/dist/src/index.js.map +6 -0
- package/dist/src/io.d.ts +14 -0
- package/dist/src/io.js +82 -0
- package/dist/src/package-identity.d.ts +4 -0
- package/dist/src/package-identity.js +6 -0
- package/dist/src/paths.d.ts +14 -0
- package/dist/src/paths.js +51 -0
- package/dist/src/persistence.d.ts +5 -0
- package/dist/src/persistence.js +5 -0
- package/dist/src/pi-runtime-adapter.d.ts +27 -0
- package/dist/src/pi-runtime-adapter.js +243 -0
- package/dist/src/pi-runtime-runner.d.ts +42 -0
- package/dist/src/pi-runtime-runner.js +505 -0
- package/dist/src/registry.d.ts +48 -0
- package/dist/src/registry.js +379 -0
- package/dist/src/retention.d.ts +17 -0
- package/dist/src/retention.js +195 -0
- package/dist/src/runtime/agent-runner.d.ts +143 -0
- package/dist/src/runtime/agent-runner.js +12 -0
- package/dist/src/runtime/index.d.ts +2 -0
- package/dist/src/runtime/index.js +2 -0
- package/dist/src/runtime/namespace.d.ts +14 -0
- package/dist/src/runtime/namespace.js +1 -0
- package/dist/src/runtime/workflow-result.d.ts +2 -0
- package/dist/src/runtime/workflow-result.js +6 -0
- package/dist/src/session-handoff.d.ts +2 -0
- package/dist/src/session-handoff.js +82 -0
- package/dist/src/session-lease.d.ts +12 -0
- package/dist/src/session-lease.js +180 -0
- package/dist/src/store.d.ts +95 -0
- package/dist/src/store.js +951 -0
- package/dist/src/subagent-manager-handle.d.ts +4 -0
- package/dist/src/subagent-manager-handle.js +6 -0
- package/dist/src/tool-timing.d.ts +12 -0
- package/dist/src/tool-timing.js +33 -0
- package/dist/src/trajectory-contracts.d.ts +8 -0
- package/dist/src/trajectory-contracts.js +9 -0
- package/dist/src/trajectory-host-handle.d.ts +12 -0
- package/dist/src/trajectory-host-handle.js +6 -0
- package/dist/src/trajectory.d.ts +128 -0
- package/dist/src/trajectory.js +594 -0
- package/dist/src/types.d.ts +960 -0
- package/dist/src/types.js +60 -0
- package/dist/src/utils.d.ts +57 -0
- package/dist/src/utils.js +225 -0
- package/dist/src/validation.d.ts +29 -0
- package/dist/src/validation.js +1100 -0
- package/dist/src/workflow-artifacts.d.ts +14 -0
- package/dist/src/workflow-artifacts.js +40 -0
- package/dist/starter/index.d.ts +1 -0
- package/dist/starter/index.js +103 -0
- package/dist/starter/index.js.map +6 -0
- package/dist/starter/review-loop.d.ts +72 -0
- package/dist/starter/review-loop.js +43 -0
- package/dist/starter/roles/developer.md +6 -0
- package/dist/starter/roles/oracle.md +18 -0
- package/dist/starter/roles/researcher.md +20 -0
- package/dist/starter/roles/reviewer.md +19 -0
- package/dist/starter/roles/scout.md +18 -0
- package/dist/subagents/index.d.ts +8 -0
- package/dist/subagents/index.js +8021 -0
- package/dist/subagents/index.js.map +6 -0
- package/dist/subagents/src/contracts.d.ts +173 -0
- package/dist/subagents/src/contracts.js +68 -0
- package/dist/subagents/src/decode.d.ts +15 -0
- package/dist/subagents/src/decode.js +182 -0
- package/dist/subagents/src/index.d.ts +12 -0
- package/dist/subagents/src/index.js +180 -0
- package/dist/subagents/src/manager.d.ts +3 -0
- package/dist/subagents/src/manager.js +1636 -0
- package/dist/subagents/src/navigator.d.ts +5 -0
- package/dist/subagents/src/navigator.js +627 -0
- package/dist/subagents/src/view.d.ts +70 -0
- package/dist/subagents/src/view.js +387 -0
- package/dist/subagents/src/worktree.d.ts +34 -0
- package/dist/subagents/src/worktree.js +68 -0
- package/dist/trajectory/assets/favicon.png +0 -0
- package/dist/trajectory/assets/index.html +772 -0
- package/dist/trajectory/assets/marked.min.js +6 -0
- package/dist/trajectory/assets/morphdom.min.js +1 -0
- package/dist/trajectory/index.d.ts +5 -0
- package/dist/trajectory/index.js +3459 -0
- package/dist/trajectory/index.js.map +6 -0
- package/dist/trajectory/src/assets/favicon.png +0 -0
- package/dist/trajectory/src/assets/index.html +772 -0
- package/dist/trajectory/src/assets/marked.min.js +6 -0
- package/dist/trajectory/src/assets/morphdom.min.js +1 -0
- package/dist/trajectory/src/export.d.ts +25 -0
- package/dist/trajectory/src/export.js +80 -0
- package/dist/trajectory/src/index.d.ts +26 -0
- package/dist/trajectory/src/index.js +732 -0
- package/dist/trajectory/src/server.d.ts +7 -0
- package/dist/trajectory/src/server.js +2534 -0
- package/dist/trajectory/src/server.js.map +6 -0
- package/examples/advanced-patterns/auto-model-router.mjs +78 -0
- package/examples/workflow-extension-template/README.md +37 -0
- package/examples/workflow-extension-template/extension.test.mjs +59 -0
- package/examples/workflow-extension-template/index.js +51 -0
- package/examples/workflow-extension-template/roles/reviewer.md +4 -0
- package/package.json +105 -0
- package/skills/pi-extensible-workflows/SKILL.md +160 -0
- package/src/agent-execution.ts +1533 -0
- package/src/background-widget.ts +1496 -0
- package/src/budget.ts +109 -0
- package/src/changelog.ts +114 -0
- package/src/decoders.ts +567 -0
- package/src/execution.ts +652 -0
- package/src/herdr.ts +240 -0
- package/src/host-delivery.ts +346 -0
- package/src/host-navigator.ts +871 -0
- package/src/host-phases.ts +264 -0
- package/src/host-recovery.ts +333 -0
- package/src/host-runtime.ts +373 -0
- package/src/host-view.ts +828 -0
- package/src/host.ts +1502 -0
- package/src/index.ts +29 -0
- package/src/io.ts +73 -0
- package/src/package-identity.ts +7 -0
- package/src/paths.ts +49 -0
- package/src/persistence.ts +11 -0
- package/src/pi-runtime-adapter.ts +247 -0
- package/src/pi-runtime-runner.ts +436 -0
- package/src/registry.ts +321 -0
- package/src/retention.ts +178 -0
- package/src/runtime/agent-runner.ts +158 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/namespace.ts +16 -0
- package/src/runtime/workflow-result.ts +8 -0
- package/src/session-handoff.ts +78 -0
- package/src/session-lease.ts +125 -0
- package/src/store.ts +832 -0
- package/src/subagent-manager-handle.ts +8 -0
- package/src/tool-timing.ts +47 -0
- package/src/trajectory-contracts.ts +13 -0
- package/src/trajectory-host-handle.ts +16 -0
- package/src/trajectory.ts +543 -0
- package/src/types.ts +374 -0
- package/src/utils.ts +192 -0
- package/src/validation.ts +910 -0
- package/src/workflow-artifacts.ts +35 -0
- package/starter/index.ts +35 -0
- package/starter/prompts/council.md +16 -0
- package/starter/prompts/deep-research.md +16 -0
- package/starter/prompts/oracle.md +14 -0
- package/starter/prompts/parallel-review.md +22 -0
- package/starter/prompts/parallel-scout.md +16 -0
- package/starter/prompts/review-loop.md +14 -0
- package/starter/prompts/review.md +14 -0
- package/starter/prompts/scout.md +14 -0
- package/starter/review-loop.ts +73 -0
- package/starter/roles/developer.md +6 -0
- package/starter/roles/oracle.md +18 -0
- package/starter/roles/researcher.md +20 -0
- package/starter/roles/reviewer.md +19 -0
- package/starter/roles/scout.md +18 -0
- package/subagents/README.md +145 -0
- package/subagents/index.ts +8 -0
- package/subagents/src/contracts.ts +176 -0
- package/subagents/src/decode.ts +158 -0
- package/subagents/src/index.ts +194 -0
- package/subagents/src/manager.ts +1560 -0
- package/subagents/src/navigator.ts +488 -0
- package/subagents/src/view.ts +376 -0
- package/subagents/src/worktree.ts +99 -0
- package/trajectory/index.ts +5 -0
- package/trajectory/src/assets/favicon.png +0 -0
- package/trajectory/src/assets/index.html +772 -0
- package/trajectory/src/assets/marked.min.js +6 -0
- package/trajectory/src/assets/morphdom.min.js +1 -0
- package/trajectory/src/export.ts +85 -0
- package/trajectory/src/index.ts +604 -0
- package/trajectory/src/server.ts +447 -0
package/src/registry.ts
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { Value } from "typebox/value";
|
|
5
|
+
import type { AgentAttemptAction, JsonSchema, JsonValue, RegisteredAgentSetupHook, WorkflowCatalog, WorkflowCatalogContext, WorkflowCatalogError, WorkflowCatalogFunction, WorkflowCatalogIndex, WorkflowCatalogModelAlias, WorkflowExtension, WorkflowExtensionSettings, WorkflowExtensionSettingsValidatorContext, WorkflowFunction, WorkflowFunctionContext, WorkflowFunctionSource, WorkflowJournal, WorkflowModelAlias, WorkflowModelAliasResolverContext, WorkflowRoleDirectoryRegistration } from "./types.js";
|
|
6
|
+
import type { SubagentRunRequest, SubagentStatus } from "../subagents/src/contracts.js";
|
|
7
|
+
import { byPriorityThenName, deepFreeze, errorCode, errorText, fail, jsonValue, MODEL_ALIAS_NAME, object, validWorkflowExtensionNamespace } from "./utils.js";
|
|
8
|
+
import { loadSettings, resolveWorkflowSettings, validateSchema } from "./validation.js";
|
|
9
|
+
import { canonicalPath } from "./paths.js";
|
|
10
|
+
import { isCorePackageName } from "./package-identity.js";
|
|
11
|
+
|
|
12
|
+
const RESERVED_GLOBALS = new Set(["agent", "shell", "prompt", "checkpoint", "parallel", "pipeline", "phase", "withWorktree", "log", "args", "Promise", "JSON", "Math", "Date", "eval", "Function", "WebAssembly", "process", "require", "module", "exports", "console", "fetch", "XMLHttpRequest", "WebSocket", "performance", "crypto", "setTimeout", "setInterval", "setImmediate", "queueMicrotask", "Intl", "SharedArrayBuffer", "Atomics", "globalThis", "global", "undefined", "NaN", "Infinity", "extensions", "workflow_catalog"]);
|
|
13
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
14
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
15
|
+
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
16
|
+
export type SubagentStatusObserver = (status: Readonly<SubagentStatus>, request: Readonly<SubagentRunRequest>) => void;
|
|
17
|
+
|
|
18
|
+
function normalizeRoleDirectory(value: unknown): string {
|
|
19
|
+
try {
|
|
20
|
+
if (value instanceof URL) {
|
|
21
|
+
if (value.protocol !== "file:") fail("INVALID_METADATA", "Workflow role directories require file URLs or absolute filesystem paths");
|
|
22
|
+
return fileURLToPath(value);
|
|
23
|
+
}
|
|
24
|
+
if (typeof value === "string") {
|
|
25
|
+
if (value.startsWith("file:")) return fileURLToPath(value);
|
|
26
|
+
if (isAbsolute(value)) return value;
|
|
27
|
+
}
|
|
28
|
+
} catch (error) {
|
|
29
|
+
fail("INVALID_METADATA", `Invalid workflow role directory: ${errorText(error)}`);
|
|
30
|
+
}
|
|
31
|
+
fail("INVALID_METADATA", "Workflow role directories require file URLs or absolute filesystem paths");
|
|
32
|
+
}
|
|
33
|
+
function isBuiltinRoleDirectory(path: string): boolean {
|
|
34
|
+
const canonical = canonicalPath(path);
|
|
35
|
+
const starterDirectory = dirname(canonical);
|
|
36
|
+
if (basename(canonical) !== "roles" || basename(starterDirectory) !== "starter") return false;
|
|
37
|
+
const distributionDirectory = dirname(starterDirectory);
|
|
38
|
+
const packageRoot = basename(distributionDirectory) === "dist" ? dirname(distributionDirectory) : distributionDirectory;
|
|
39
|
+
try {
|
|
40
|
+
const metadata: unknown = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
|
41
|
+
return object(metadata) && isCorePackageName(metadata.name);
|
|
42
|
+
} catch { return false; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function catalogSchema(schema: unknown, at: string): JsonSchema {
|
|
46
|
+
validateSchema(schema, at);
|
|
47
|
+
return structuredClone(schema);
|
|
48
|
+
}
|
|
49
|
+
export class WorkflowRegistry {
|
|
50
|
+
readonly #extensions = new Set<Readonly<WorkflowExtension>>();
|
|
51
|
+
readonly #functionSources = new Map<string, WorkflowFunctionSource>();
|
|
52
|
+
readonly #globals = new Map<string, string>();
|
|
53
|
+
readonly #hooks = new Map<string, RegisteredAgentSetupHook>();
|
|
54
|
+
readonly #agentAttemptActions = new Map<string, AgentAttemptAction>();
|
|
55
|
+
readonly #roleDirectories = new Map<string, WorkflowRoleDirectoryRegistration>();
|
|
56
|
+
readonly #modelAliases = new Map<string, { name: string; version: string; headline: string; resolve: WorkflowModelAlias["resolve"] }>();
|
|
57
|
+
readonly #settingsValidators = new Set<{ headline: string; validate: NonNullable<WorkflowExtension["validateSettings"]> }>();
|
|
58
|
+
#frozen = false;
|
|
59
|
+
#subagentStatusObserver: SubagentStatusObserver | undefined;
|
|
60
|
+
|
|
61
|
+
get frozen(): boolean { return this.#frozen; }
|
|
62
|
+
freeze(): void { this.#frozen = true; }
|
|
63
|
+
setSubagentStatusObserver(observer: SubagentStatusObserver | undefined): void { this.#subagentStatusObserver = observer; }
|
|
64
|
+
observeSubagentStatus(status: Readonly<SubagentStatus>, request: Readonly<SubagentRunRequest>): void { this.#subagentStatusObserver?.(status, request); }
|
|
65
|
+
|
|
66
|
+
register(extension: WorkflowExtension): void {
|
|
67
|
+
if (this.#frozen) fail("REGISTRY_FROZEN", "Workflow extension registration is closed after session_start");
|
|
68
|
+
if (object(extension) && Object.prototype.hasOwnProperty.call(extension, "workflows")) fail("INVALID_METADATA", "Separate registered workflow definitions were removed; register a function with input and output schemas instead");
|
|
69
|
+
if (!object(extension) || Object.keys(extension).some((key) => !["version", "headline", "description", "source", "dependencies", "validateSettings", "functions", "modelAliases", "agentSetupHooks", "agentAttemptActions", "roleDirectories"].includes(key)) || typeof extension.version !== "string" || !SEMVER.test(extension.version) || typeof extension.headline !== "string" || !extension.headline.trim()) fail("INVALID_METADATA", "Workflow extensions require a semantic version and non-empty headline");
|
|
70
|
+
const functions = extension.functions ?? {};
|
|
71
|
+
const modelAliases = extension.modelAliases ?? {};
|
|
72
|
+
const agentSetupHooks = extension.agentSetupHooks ?? {};
|
|
73
|
+
const agentAttemptActions = extension.agentAttemptActions ?? {};
|
|
74
|
+
const source = extension.source;
|
|
75
|
+
const dependencyValues = extension.dependencies;
|
|
76
|
+
const dependencies: readonly string[] = dependencyValues ?? [];
|
|
77
|
+
const validateSettings = extension.validateSettings;
|
|
78
|
+
if (validateSettings !== undefined && typeof validateSettings !== "function") fail("INVALID_METADATA", "Workflow extension validateSettings must be a function");
|
|
79
|
+
if (source !== undefined && (typeof source !== "string" || !source.trim())) fail("INVALID_METADATA", "Workflow extension source must be a non-empty module URL");
|
|
80
|
+
if (dependencyValues !== undefined && (!Array.isArray(dependencyValues) || dependencyValues.some((dependency) => typeof dependency !== "string" || dependency.trim() !== dependency || !PACKAGE_NAME.test(dependency)) || new Set(dependencyValues).size !== dependencyValues.length)) fail("INVALID_METADATA", "Workflow extension dependencies must be unique, non-empty package names");
|
|
81
|
+
const roleDirectoryValues = extension.roleDirectories === undefined ? [] : extension.roleDirectories;
|
|
82
|
+
if (!Array.isArray(roleDirectoryValues)) fail("INVALID_METADATA", "Workflow extension roleDirectories must be an array");
|
|
83
|
+
const roleDirectories = [...new Set(Array.from(roleDirectoryValues, (value) => normalizeRoleDirectory(value)))];
|
|
84
|
+
if (!object(functions) || !object(modelAliases) || !object(agentSetupHooks) || !object(agentAttemptActions) || (Object.keys(functions).length === 0 && Object.keys(modelAliases).length === 0 && Object.keys(agentSetupHooks).length === 0 && Object.keys(agentAttemptActions).length === 0 && roleDirectories.length === 0 && validateSettings === undefined)) fail("INVALID_METADATA", "Workflow extensions require functions, model aliases, agent setup hooks, agent attempt actions, role directories, or a settings validator");
|
|
85
|
+
const names = Object.keys(functions);
|
|
86
|
+
if (new Set(names).size !== names.length) fail("GLOBAL_COLLISION", "Global name collision inside extension");
|
|
87
|
+
for (const name of names) {
|
|
88
|
+
if (!IDENTIFIER.test(name) || name.startsWith("__pi_extensible_workflows_")) fail("INVALID_METADATA", `Invalid global name: ${name}`);
|
|
89
|
+
if (RESERVED_GLOBALS.has(name)) fail("GLOBAL_COLLISION", `Global name is reserved: ${name}`);
|
|
90
|
+
if (this.#globals.has(name)) fail("GLOBAL_COLLISION", `Global name is already registered: ${name}`);
|
|
91
|
+
}
|
|
92
|
+
for (const [name, fn] of Object.entries(functions)) {
|
|
93
|
+
if (!object(fn) || Object.keys(fn).some((key) => !["description", "input", "output", "run"].includes(key)) || typeof fn.description !== "string" || !fn.description.trim() || typeof fn.run !== "function") fail("INVALID_METADATA", `Invalid workflow function: ${name}`);
|
|
94
|
+
validateSchema(fn.input, `${name} input`);
|
|
95
|
+
validateSchema(fn.output, `${name} output`);
|
|
96
|
+
if (fn.input.type !== "object") fail("INVALID_SCHEMA", `${name} input must describe one object`);
|
|
97
|
+
}
|
|
98
|
+
for (const [name, alias] of Object.entries(modelAliases)) {
|
|
99
|
+
if (!MODEL_ALIAS_NAME.test(name)) fail("INVALID_METADATA", `Invalid model alias name: ${name}`);
|
|
100
|
+
if (!object(alias) || Object.keys(alias).some((key) => key !== "resolve") || typeof alias.resolve !== "function") fail("INVALID_METADATA", `Invalid model alias resolver: ${name}`);
|
|
101
|
+
if (this.#modelAliases.has(name)) fail("DUPLICATE_NAME", `Model alias already registered: ${name}`);
|
|
102
|
+
}
|
|
103
|
+
for (const [name, hook] of Object.entries(agentSetupHooks)) {
|
|
104
|
+
if (!IDENTIFIER.test(name) || !object(hook) || Object.keys(hook).some((key) => !["priority", "setup"].includes(key)) || typeof hook.setup !== "function" || hook.priority !== undefined && (typeof hook.priority !== "number" || !Number.isFinite(hook.priority))) fail("INVALID_METADATA", `Invalid agent setup hook: ${name}`);
|
|
105
|
+
if (this.#hooks.has(name)) fail("DUPLICATE_NAME", `Agent setup hook already registered: ${name}`);
|
|
106
|
+
}
|
|
107
|
+
for (const [name, action] of Object.entries(agentAttemptActions)) {
|
|
108
|
+
if (!IDENTIFIER.test(name) || !object(action) || Object.keys(action).some((key) => !["label", "visible", "run", "visibleStandalone", "runStandalone"].includes(key)) || typeof action.label !== "string" || !action.label.trim() || typeof action.visible !== "function" || typeof action.run !== "function" || action.visibleStandalone !== undefined && typeof action.visibleStandalone !== "function" || action.runStandalone !== undefined && typeof action.runStandalone !== "function") fail("INVALID_METADATA", `Invalid agent attempt action: ${name}`);
|
|
109
|
+
if ((action.visibleStandalone !== undefined) !== (action.runStandalone !== undefined)) fail("INVALID_METADATA", `Standalone agent attempt actions require visibleStandalone and runStandalone: ${name}`);
|
|
110
|
+
if (this.#agentAttemptActions.has(name)) fail("DUPLICATE_NAME", `Agent attempt action already registered: ${name}`);
|
|
111
|
+
}
|
|
112
|
+
const stored = deepFreeze({ ...extension, functions, modelAliases, agentSetupHooks, agentAttemptActions, ...(roleDirectories.length ? { roleDirectories } : {}) });
|
|
113
|
+
if (source !== undefined) for (const name of names) this.#functionSources.set(name, Object.freeze({ module: source, export: "default", dependencies: Object.freeze([...dependencies]) }));
|
|
114
|
+
this.#extensions.add(stored);
|
|
115
|
+
for (const directory of roleDirectories) if (!this.#roleDirectories.has(directory)) this.#roleDirectories.set(directory, deepFreeze({ path: directory, extension: { version: extension.version, headline: extension.headline }, ...(isBuiltinRoleDirectory(directory) ? { builtin: true as const } : {}) }));
|
|
116
|
+
for (const name of names) this.#globals.set(name, name);
|
|
117
|
+
for (const [name, alias] of Object.entries(modelAliases)) this.#modelAliases.set(name, { name, version: extension.version, headline: extension.headline, resolve: alias.resolve });
|
|
118
|
+
for (const [name, hook] of Object.entries(agentSetupHooks)) this.#hooks.set(name, { name, priority: hook.priority ?? 10, setup: hook.setup });
|
|
119
|
+
for (const [name, action] of Object.entries(agentAttemptActions)) this.#agentAttemptActions.set(name, action);
|
|
120
|
+
if (validateSettings !== undefined) this.#settingsValidators.add({ headline: extension.headline, validate: validateSettings });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function(name: string): WorkflowFunction {
|
|
124
|
+
if (!IDENTIFIER.test(name)) fail("MISSING_WORKFLOW", `Registered functions require an unqualified name: ${name}`);
|
|
125
|
+
const fn = [...this.#extensions].find((extension) => extension.functions?.[name])?.functions?.[name];
|
|
126
|
+
if (!fn) fail("MISSING_WORKFLOW", `Registered function is unavailable: ${name}; the separate registered-workflow format was removed`);
|
|
127
|
+
return fn;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
functions(): Readonly<Record<string, WorkflowFunction>> {
|
|
131
|
+
return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((extension) => Object.entries(extension.functions ?? {}))));
|
|
132
|
+
}
|
|
133
|
+
functionSources(): Readonly<Record<string, WorkflowFunctionSource>> {
|
|
134
|
+
return Object.freeze(Object.fromEntries(this.#functionSources));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
catalog(context?: WorkflowCatalogContext): WorkflowCatalog {
|
|
138
|
+
const functions: WorkflowCatalogFunction[] = [];
|
|
139
|
+
for (const extension of this.#extensions) {
|
|
140
|
+
for (const [name, fn] of Object.entries(extension.functions ?? {})) functions.push({ name, version: extension.version, headline: extension.headline, description: fn.description, input: catalogSchema(fn.input, `${name} input`), output: catalogSchema(fn.output, `${name} output`) });
|
|
141
|
+
}
|
|
142
|
+
let aliases: Readonly<Record<string, string>> | undefined;
|
|
143
|
+
let settings: WorkflowCatalog["settings"];
|
|
144
|
+
let source = "global settings";
|
|
145
|
+
try {
|
|
146
|
+
const resolved = context ? resolveWorkflowSettings(context.cwd, context.projectTrusted, context.globalSettingsPath) : undefined;
|
|
147
|
+
aliases = resolved?.effective.modelAliases ?? loadSettings().modelAliases;
|
|
148
|
+
if (resolved) { source = resolved.projectTrusted && [resolved.sources.modelAliases, resolved.sources.skills, resolved.sources.extensions, resolved.sources.tools].some((path) => path === resolved.projectSettingsPath) ? "trusted project settings" : "global settings"; settings = { concurrency: resolved.effective.concurrency, backgroundWidget: resolved.effective.backgroundWidget ?? true, modelAliases: resolved.effective.modelAliases ?? {}, skills: resolved.effective.skills ?? [], extensions: Array.isArray(resolved.effective.extensions) ? resolved.effective.extensions : [], tools: resolved.effective.tools ?? [], ...(resolved.effective.extensionSettings === undefined ? {} : { extensionSettings: resolved.effective.extensionSettings }), ...(resolved.effective.worktreePostCreateCommand === undefined ? {} : { worktreePostCreateCommand: resolved.effective.worktreePostCreateCommand }), globalSettingsPath: resolved.globalSettingsPath, projectSettingsPath: resolved.projectSettingsPath, projectTrusted: resolved.projectTrusted, sources: resolved.sources }; }
|
|
149
|
+
} catch { aliases = undefined; }
|
|
150
|
+
const staticEntries: WorkflowCatalogModelAlias[] = Object.keys(aliases ?? {}).map((name) => ({ name, kind: "static", provenance: source }));
|
|
151
|
+
const dynamicEntries: WorkflowCatalogModelAlias[] = [...this.#modelAliases.values()].map(({ name, version, headline }) => ({ name, kind: "dynamic", provenance: `extension: ${headline}`, version, headline }));
|
|
152
|
+
const modelAliasEntries = [...staticEntries, ...dynamicEntries].sort((left, right) => left.name.localeCompare(right.name) || left.kind.localeCompare(right.kind) || left.provenance.localeCompare(right.provenance));
|
|
153
|
+
const sort = (left: { name: string }, right: { name: string }) => left.name.localeCompare(right.name);
|
|
154
|
+
const catalog: WorkflowCatalog = { functions: functions.sort(sort), ...(modelAliasEntries.length ? { modelAliasEntries } : {}) };
|
|
155
|
+
if (aliases && Object.keys(aliases).length) Object.defineProperty(catalog, "modelAliases", { value: Object.freeze(structuredClone(aliases)), enumerable: false });
|
|
156
|
+
if (settings) { const publicSettings = { ...settings, modelAliases: Object.keys(aliases ?? {}).length ? {} : settings.modelAliases }; Object.defineProperty(catalog, "settings", { value: deepFreeze(publicSettings), enumerable: true }); }
|
|
157
|
+
return deepFreeze(catalog);
|
|
158
|
+
}
|
|
159
|
+
catalogIndex(context?: WorkflowCatalogContext): WorkflowCatalogIndex {
|
|
160
|
+
const catalog = this.catalog(context);
|
|
161
|
+
let modelAliasEntries: WorkflowCatalogModelAlias[] | undefined;
|
|
162
|
+
if (catalog.modelAliasEntries !== undefined) {
|
|
163
|
+
const winningEntries = new Map<string, WorkflowCatalogModelAlias>();
|
|
164
|
+
for (const entry of catalog.modelAliasEntries) {
|
|
165
|
+
if (!winningEntries.has(entry.name) || entry.kind === "static") winningEntries.set(entry.name, entry);
|
|
166
|
+
}
|
|
167
|
+
modelAliasEntries = [...winningEntries.values()];
|
|
168
|
+
}
|
|
169
|
+
const index: WorkflowCatalogIndex = {
|
|
170
|
+
functions: catalog.functions.map(({ name, description, input }) => ({ name, description, input: structuredClone(input) })),
|
|
171
|
+
...(modelAliasEntries ? { modelAliasEntries: structuredClone(modelAliasEntries) } : {}),
|
|
172
|
+
};
|
|
173
|
+
if (catalog.modelAliases) Object.defineProperty(index, "modelAliases", { value: Object.freeze(structuredClone(catalog.modelAliases)), enumerable: false });
|
|
174
|
+
if (catalog.settings) Object.defineProperty(index, "settings", { value: deepFreeze(structuredClone(catalog.settings)), enumerable: true });
|
|
175
|
+
return deepFreeze(index);
|
|
176
|
+
}
|
|
177
|
+
catalogDetail(name: string, context?: WorkflowCatalogContext): WorkflowCatalogFunction | WorkflowCatalogModelAlias | WorkflowCatalogError {
|
|
178
|
+
const catalog = this.catalog(context);
|
|
179
|
+
const entry = catalog.functions.find((candidate) => candidate.name === name);
|
|
180
|
+
if (entry) return entry;
|
|
181
|
+
const alias = catalog.modelAliasEntries?.find((candidate) => candidate.name === name && candidate.kind === "static")
|
|
182
|
+
?? catalog.modelAliasEntries?.find((candidate) => candidate.name === name);
|
|
183
|
+
if (alias) return alias;
|
|
184
|
+
return deepFreeze({ error: { code: "NOT_FOUND", name, message: `No registered workflow function is available: ${name}` } });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
globals(): Readonly<Record<string, { name: string }>> {
|
|
188
|
+
return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((extension) => Object.keys(extension.functions ?? {}).map((name) => [name, { name }]))));
|
|
189
|
+
}
|
|
190
|
+
validateExtensionSettings(settings: Readonly<WorkflowExtensionSettings> | undefined, context: WorkflowExtensionSettingsValidatorContext): void {
|
|
191
|
+
const effectiveSettings = settings ?? Object.freeze({});
|
|
192
|
+
if (!jsonValue(effectiveSettings) || !object(effectiveSettings)) fail("INVALID_SETTINGS", "Workflow extension settings must be a JSON object");
|
|
193
|
+
const unsafeNamespace = Object.keys(effectiveSettings).find((namespace) => !validWorkflowExtensionNamespace(namespace));
|
|
194
|
+
if (unsafeNamespace !== undefined) fail("INVALID_SETTINGS", `Workflow extension settings contains an invalid namespace: ${unsafeNamespace}`);
|
|
195
|
+
const frozenSettings = deepFreeze(structuredClone(effectiveSettings));
|
|
196
|
+
for (const { headline, validate } of this.#settingsValidators) {
|
|
197
|
+
try { validate(frozenSettings, Object.freeze({ ...context })); }
|
|
198
|
+
catch (error) {
|
|
199
|
+
const location = context.source === "role" && context.role ? `role ${context.role}` : context.source;
|
|
200
|
+
fail("INVALID_SETTINGS", `Invalid extension settings (${headline}; ${location}): ${errorText(error)}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async invokeFunction(name: string, input: unknown, context: Readonly<WorkflowFunctionContext>, path: string, journal: WorkflowJournal): Promise<JsonValue> {
|
|
205
|
+
const fn = this.function(name);
|
|
206
|
+
if (!object(input) || !jsonValue(input) || !Value.Check(fn.input, input)) fail("RESULT_INVALID", `Invalid input for ${name}`);
|
|
207
|
+
const replayed = journal.get(path);
|
|
208
|
+
if (replayed !== undefined) {
|
|
209
|
+
if (!jsonValue(replayed) || !Value.Check(fn.output, replayed)) fail("RESULT_INVALID", `Invalid replay for ${name}`);
|
|
210
|
+
return structuredClone(replayed);
|
|
211
|
+
}
|
|
212
|
+
const result: unknown = await fn.run(deepFreeze(structuredClone(input)), Object.freeze({ run: context.run, settings: deepFreeze(structuredClone(context.settings)), invoke: context.invoke, agent: context.agent, shell: context.shell, prompt: context.prompt, parallel: context.parallel, pipeline: context.pipeline, withWorktree: context.withWorktree, checkpoint: context.checkpoint, phase: context.phase, log: context.log }));
|
|
213
|
+
if (!jsonValue(result) || !Value.Check(fn.output, result)) fail("RESULT_INVALID", `Invalid output from ${name}`);
|
|
214
|
+
const stored = structuredClone(result);
|
|
215
|
+
journal.put(path, stored);
|
|
216
|
+
return structuredClone(stored);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
agentSetupHooks(): readonly RegisteredAgentSetupHook[] {
|
|
220
|
+
return [...this.#hooks.values()].sort(byPriorityThenName);
|
|
221
|
+
}
|
|
222
|
+
agentAttemptActions(): Readonly<Record<string, AgentAttemptAction>> { return Object.freeze(Object.fromEntries(this.#agentAttemptActions.entries())); }
|
|
223
|
+
roleDirectories(): readonly string[] {
|
|
224
|
+
return [...this.#roleDirectories.keys()];
|
|
225
|
+
}
|
|
226
|
+
roleDirectoryRegistrations(): readonly WorkflowRoleDirectoryRegistration[] {
|
|
227
|
+
return [...this.#roleDirectories.values()];
|
|
228
|
+
}
|
|
229
|
+
modelAliases(): readonly { name: string; version: string; headline: string; resolve: WorkflowModelAlias["resolve"] }[] { return [...this.#modelAliases.values()].sort((left, right) => left.name.localeCompare(right.name)); }
|
|
230
|
+
async resolveModelAliases(context: Readonly<WorkflowModelAliasResolverContext>, shadowed: ReadonlySet<string> = new Set()): Promise<Readonly<Record<string, string>>> {
|
|
231
|
+
const resolved: Record<string, string> = {};
|
|
232
|
+
const isAborted = (): boolean => context.signal.aborted;
|
|
233
|
+
for (const alias of this.modelAliases()) {
|
|
234
|
+
if (shadowed.has(alias.name)) continue;
|
|
235
|
+
if (isAborted()) fail("CANCELLED", `Model alias resolver cancelled: ${alias.name} (${alias.headline})`);
|
|
236
|
+
let target: unknown;
|
|
237
|
+
try { target = await alias.resolve(Object.freeze({ cwd: context.cwd, projectTrusted: context.projectTrusted, rootModel: Object.freeze({ ...context.rootModel }), knownModels: new Set(context.knownModels), availableModels: new Set(context.availableModels), signal: context.signal })); }
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (isAborted() || error instanceof Error && error.name === "AbortError" || errorCode(error) === "CANCELLED") fail("CANCELLED", `Model alias resolver cancelled: ${alias.name} (${alias.headline})`);
|
|
240
|
+
fail("CONFIG_ERROR", `Model alias resolver failed for ${alias.name} (${alias.headline}): ${errorText(error)}`);
|
|
241
|
+
}
|
|
242
|
+
if (isAborted()) fail("CANCELLED", `Model alias resolver cancelled: ${alias.name} (${alias.headline})`);
|
|
243
|
+
if (typeof target !== "string" || !target.trim()) fail("CONFIG_ERROR", `Model alias resolver returned an invalid target for ${alias.name} (${alias.headline})`);
|
|
244
|
+
resolved[alias.name] = target.trim();
|
|
245
|
+
}
|
|
246
|
+
return Object.freeze(resolved);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
export type WorkflowRegistryApi = Pick<WorkflowRegistry, "frozen" | "freeze" | "register" | "function" | "functions" | "functionSources" | "catalog" | "catalogIndex" | "catalogDetail" | "globals" | "invokeFunction" | "validateExtensionSettings" | "modelAliases" | "resolveModelAliases" | "agentSetupHooks" | "agentAttemptActions" | "roleDirectories" | "roleDirectoryRegistrations" | "setSubagentStatusObserver" | "observeSubagentStatus">;
|
|
250
|
+
interface WorkflowRegistryHost { api: WorkflowRegistryApi; activeHosts: number }
|
|
251
|
+
const WORKFLOW_REGISTRY_KEY = Symbol.for("pi-extensible-workflows.workflow-registry");
|
|
252
|
+
const globalRegistry = globalThis as typeof globalThis & Record<symbol, WorkflowRegistryHost | undefined>;
|
|
253
|
+
function createWorkflowRegistryApi(registry: WorkflowRegistry): WorkflowRegistryApi {
|
|
254
|
+
return {
|
|
255
|
+
get frozen() { return registry.frozen; },
|
|
256
|
+
freeze: () => { registry.freeze(); },
|
|
257
|
+
setSubagentStatusObserver: (observer) => { registry.setSubagentStatusObserver(observer); },
|
|
258
|
+
observeSubagentStatus: (status, request) => { registry.observeSubagentStatus(status, request); },
|
|
259
|
+
register: (extension) => { registry.register(extension); },
|
|
260
|
+
function: (name) => registry.function(name),
|
|
261
|
+
functions: () => registry.functions(),
|
|
262
|
+
functionSources: () => registry.functionSources(),
|
|
263
|
+
catalog: (context) => registry.catalog(context),
|
|
264
|
+
catalogIndex: (context) => registry.catalogIndex(context),
|
|
265
|
+
catalogDetail: (name, context) => registry.catalogDetail(name, context),
|
|
266
|
+
globals: () => registry.globals(),
|
|
267
|
+
invokeFunction: (...args) => registry.invokeFunction(...args),
|
|
268
|
+
validateExtensionSettings: (...args) => { registry.validateExtensionSettings(...args); },
|
|
269
|
+
modelAliases: () => registry.modelAliases(),
|
|
270
|
+
resolveModelAliases: (...args) => registry.resolveModelAliases(...args),
|
|
271
|
+
roleDirectories: () => registry.roleDirectories(),
|
|
272
|
+
roleDirectoryRegistrations: () => registry.roleDirectoryRegistrations(),
|
|
273
|
+
agentSetupHooks: () => registry.agentSetupHooks(),
|
|
274
|
+
agentAttemptActions: () => registry.agentAttemptActions(),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function workflowRegistryHost(): WorkflowRegistryHost {
|
|
278
|
+
return globalRegistry[WORKFLOW_REGISTRY_KEY] ??= { api: createWorkflowRegistryApi(new WorkflowRegistry()), activeHosts: 0 };
|
|
279
|
+
}
|
|
280
|
+
export function resetWorkflowRegistry(): void {
|
|
281
|
+
workflowRegistryHost().api = createWorkflowRegistryApi(new WorkflowRegistry());
|
|
282
|
+
}
|
|
283
|
+
export function resetWorkflowRegistryIfIdle(): void {
|
|
284
|
+
if (workflowRegistryHost().activeHosts === 0) resetWorkflowRegistry();
|
|
285
|
+
}
|
|
286
|
+
export function beginWorkflowExtensionLoading(): void {
|
|
287
|
+
const host = workflowRegistryHost();
|
|
288
|
+
if (host.api.frozen && host.activeHosts === 0) resetWorkflowRegistry();
|
|
289
|
+
}
|
|
290
|
+
export function retainWorkflowRegistry(): () => void {
|
|
291
|
+
const host = workflowRegistryHost();
|
|
292
|
+
host.activeHosts += 1;
|
|
293
|
+
let retained = true;
|
|
294
|
+
return () => {
|
|
295
|
+
if (!retained) return;
|
|
296
|
+
retained = false;
|
|
297
|
+
host.activeHosts -= 1;
|
|
298
|
+
if (host.activeHosts === 0) resetWorkflowRegistry();
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
export function loadingRegistry(): WorkflowRegistryApi { return workflowRegistryHost().api; }
|
|
302
|
+
beginWorkflowExtensionLoading();
|
|
303
|
+
export function registerWorkflowExtension(extension: WorkflowExtension): void { loadingRegistry().register(extension); }
|
|
304
|
+
export function workflowCatalog(context?: WorkflowCatalogContext): WorkflowCatalog { return loadingRegistry().catalog(context); }
|
|
305
|
+
export function workflowCatalogIndex(context?: WorkflowCatalogContext): WorkflowCatalogIndex { return loadingRegistry().catalogIndex(context); }
|
|
306
|
+
export function workflowCatalogDetail(name: string, context?: WorkflowCatalogContext): WorkflowCatalogFunction | WorkflowCatalogModelAlias | WorkflowCatalogError { return loadingRegistry().catalogDetail(name, context); }
|
|
307
|
+
export function registeredWorkflowFunctions(): Readonly<Record<string, WorkflowFunction>> { return loadingRegistry().functions(); }
|
|
308
|
+
export function registeredWorkflowFunctionSources(): Readonly<Record<string, WorkflowFunctionSource>> {
|
|
309
|
+
const sources = loadingRegistry().functionSources;
|
|
310
|
+
return typeof sources === "function" ? sources() : {};
|
|
311
|
+
}
|
|
312
|
+
export function registeredWorkflowRoleDirectories(): readonly string[] {
|
|
313
|
+
const directories = loadingRegistry().roleDirectories;
|
|
314
|
+
return typeof directories === "function" ? directories() : [];
|
|
315
|
+
}
|
|
316
|
+
export function registeredWorkflowRoleDirectoryRegistrations(): readonly WorkflowRoleDirectoryRegistration[] {
|
|
317
|
+
const registrations = loadingRegistry().roleDirectoryRegistrations;
|
|
318
|
+
return typeof registrations === "function" ? registrations() : [];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export type { WorkflowCatalog, WorkflowCatalogContext, WorkflowCatalogError, WorkflowCatalogFunction, WorkflowCatalogIndex, WorkflowCatalogIndexFunction, WorkflowCatalogModelAlias, WorkflowCatalogSettings, WorkflowRoleDirectoryRegistration } from "./types.js";
|
package/src/retention.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { lstat, readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { HARD_TERMINAL_RUN_STATES, WorkflowError, type LaunchSnapshot, type WorkflowRetentionSettings } from "./types.js";
|
|
5
|
+
import { acquireSessionLease, listPersistedSessionIds, listRunIds, RunStore, type BorrowedWorktreeBinding, type PersistedRun, type SessionLease } from "./persistence.js";
|
|
6
|
+
|
|
7
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
const REQUIRED_FILES = new Set(["workflow.js", "state.json", "snapshot.json", "journal.json", "ownership.json", "worktrees.json", "borrowed-worktrees.json", "system-prompts.json"]);
|
|
9
|
+
const OPTIONAL_FILES = new Set(["result.json", "summary.json"]);
|
|
10
|
+
const REQUIRED_DIRECTORIES = new Set(["worktrees", ".system-prompts"]);
|
|
11
|
+
|
|
12
|
+
type StoredRun = { sessionId: string; runId: string; store: RunStore; run: PersistedRun; stateMtimeMs: number; dependencies: readonly string[] };
|
|
13
|
+
|
|
14
|
+
export interface RetentionCleanupOptions {
|
|
15
|
+
cwd: string;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
home?: string;
|
|
18
|
+
allSessions?: boolean;
|
|
19
|
+
retention: Readonly<WorkflowRetentionSettings>;
|
|
20
|
+
now?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RetentionCleanupReport {
|
|
24
|
+
candidates: readonly string[];
|
|
25
|
+
deleted: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function validateInventory(store: RunStore, loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }): Promise<void> {
|
|
29
|
+
const entries = await readdir(store.directory, { withFileTypes: true });
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
if (REQUIRED_DIRECTORIES.has(entry.name)) {
|
|
32
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error(`Run artifact is not a regular directory: ${join(store.directory, entry.name)}`);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (!REQUIRED_FILES.has(entry.name) && !OPTIONAL_FILES.has(entry.name)) throw new Error(`Run inventory contains an unrecognized artifact: ${join(store.directory, entry.name)}`);
|
|
36
|
+
if (!entry.isFile() || entry.isSymbolicLink()) throw new Error(`Run artifact is not a regular file: ${join(store.directory, entry.name)}`);
|
|
37
|
+
}
|
|
38
|
+
for (const name of REQUIRED_FILES) {
|
|
39
|
+
const entry = await lstat(join(store.directory, name));
|
|
40
|
+
if (!entry.isFile() || entry.isSymbolicLink()) throw new Error(`Run artifact is not a regular file: ${join(store.directory, name)}`);
|
|
41
|
+
}
|
|
42
|
+
if (loaded.run.state === "completed") {
|
|
43
|
+
const result = await lstat(join(store.directory, "result.json"));
|
|
44
|
+
if (!result.isFile() || result.isSymbolicLink()) throw new Error(`Run artifact is not a regular file: ${join(store.directory, "result.json")}`);
|
|
45
|
+
}
|
|
46
|
+
if (await readFile(join(store.directory, "workflow.js"), "utf8") !== loaded.snapshot.script) throw new Error("Persisted workflow source does not match its launch snapshot");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The runs this run cannot outlive: its parent, its retry lineage, and every run it borrows a named worktree from. */
|
|
50
|
+
export function runDependencyIds(run: Pick<PersistedRun, "parentRunId" | "retry">, borrowed: readonly BorrowedWorktreeBinding[]): string[] {
|
|
51
|
+
const dependencies = new Set<string>();
|
|
52
|
+
if (run.parentRunId !== undefined) dependencies.add(run.parentRunId);
|
|
53
|
+
if (run.retry) {
|
|
54
|
+
dependencies.add(run.retry.sourceRunId);
|
|
55
|
+
dependencies.add(run.retry.lineageRootRunId);
|
|
56
|
+
}
|
|
57
|
+
for (const binding of borrowed) dependencies.add(binding.sourceRunId);
|
|
58
|
+
return [...dependencies];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function scanRun(cwd: string, sessionId: string, runId: string, home: string): Promise<StoredRun> {
|
|
62
|
+
const store = new RunStore(cwd, sessionId, runId, home);
|
|
63
|
+
const loaded = await store.load();
|
|
64
|
+
await validateInventory(store, loaded);
|
|
65
|
+
await store.loadSummary();
|
|
66
|
+
await store.loadOwnership();
|
|
67
|
+
await store.systemPrompts();
|
|
68
|
+
await store.validateDeletionWorktrees();
|
|
69
|
+
await store.validateBorrowedWorktrees();
|
|
70
|
+
if (loaded.run.retry) await store.validateRetrySource();
|
|
71
|
+
const stateMtimeMs = (await stat(join(store.directory, "state.json"))).mtimeMs;
|
|
72
|
+
const dependencies = runDependencyIds(loaded.run, await store.borrowedWorktrees());
|
|
73
|
+
if (dependencies.includes(runId)) throw new Error(`Run ${runId} depends on itself`);
|
|
74
|
+
return { sessionId, runId, store, run: loaded.run, stateMtimeMs, dependencies };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function scanSession(cwd: string, sessionId: string, home: string): Promise<StoredRun[]> {
|
|
78
|
+
const runs: StoredRun[] = [];
|
|
79
|
+
for (const runId of await listRunIds(cwd, sessionId, home)) runs.push(await scanRun(cwd, sessionId, runId, home));
|
|
80
|
+
return runs;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function runKey(sessionId: string, runId: string): string { return `${sessionId}\0${runId}`; }
|
|
84
|
+
|
|
85
|
+
function validateDependencies(runs: readonly StoredRun[]): void {
|
|
86
|
+
const byKey = new Map(runs.map((run) => [runKey(run.sessionId, run.runId), run]));
|
|
87
|
+
for (const run of runs) for (const dependency of run.dependencies) if (!byKey.has(runKey(run.sessionId, dependency))) throw new Error(`Run ${run.runId} depends on missing run ${dependency}`);
|
|
88
|
+
const visiting = new Set<string>();
|
|
89
|
+
const visited = new Set<string>();
|
|
90
|
+
const visit = (sessionId: string, runId: string): void => {
|
|
91
|
+
const key = runKey(sessionId, runId);
|
|
92
|
+
if (visiting.has(key)) throw new Error("Persisted run dependency cycle prevents safe cleanup");
|
|
93
|
+
if (visited.has(key)) return;
|
|
94
|
+
visiting.add(key);
|
|
95
|
+
for (const dependency of byKey.get(key)?.dependencies ?? []) visit(sessionId, dependency);
|
|
96
|
+
visiting.delete(key);
|
|
97
|
+
visited.add(key);
|
|
98
|
+
};
|
|
99
|
+
for (const run of runs) visit(run.sessionId, run.runId);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function eligibleRuns(runs: readonly StoredRun[], retention: Readonly<WorkflowRetentionSettings>, now: number): Set<string> {
|
|
103
|
+
const terminal = runs.filter(({ run }) => HARD_TERMINAL_RUN_STATES.has(run.state));
|
|
104
|
+
const eligible = new Set<string>();
|
|
105
|
+
if (retention.olderThanDays !== undefined) {
|
|
106
|
+
const cutoffMs = now - retention.olderThanDays * DAY_MS;
|
|
107
|
+
if (!Number.isFinite(cutoffMs)) throw new Error("Retention olderThanDays produces an unrepresentable cutoff");
|
|
108
|
+
for (const run of terminal) if (run.stateMtimeMs < cutoffMs) eligible.add(runKey(run.sessionId, run.runId));
|
|
109
|
+
}
|
|
110
|
+
if (retention.maxTerminalRuns !== undefined) {
|
|
111
|
+
const newest = [...terminal].sort((left, right) => right.stateMtimeMs - left.stateMtimeMs || right.runId.localeCompare(left.runId));
|
|
112
|
+
for (const run of newest.slice(retention.maxTerminalRuns)) eligible.add(runKey(run.sessionId, run.runId));
|
|
113
|
+
}
|
|
114
|
+
return eligible;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function cleanupOrder(runs: readonly StoredRun[], candidates: ReadonlySet<string>): readonly StoredRun[] {
|
|
118
|
+
const remaining = new Set(candidates);
|
|
119
|
+
const ordered: StoredRun[] = [];
|
|
120
|
+
while (remaining.size) {
|
|
121
|
+
const next = runs.find((run) => remaining.has(runKey(run.sessionId, run.runId)) && !runs.some((child) => remaining.has(runKey(child.sessionId, child.runId)) && child.sessionId === run.sessionId && child.dependencies.includes(run.runId)));
|
|
122
|
+
if (!next) throw new Error("Dependency cycle prevents safe cleanup");
|
|
123
|
+
ordered.push(next);
|
|
124
|
+
remaining.delete(runKey(next.sessionId, next.runId));
|
|
125
|
+
}
|
|
126
|
+
return ordered;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function recheck(run: StoredRun): Promise<void> {
|
|
130
|
+
const before = await stat(join(run.store.directory, "state.json"));
|
|
131
|
+
const loaded = await run.store.load();
|
|
132
|
+
const after = await stat(join(run.store.directory, "state.json"));
|
|
133
|
+
if (loaded.run.id !== run.runId || !HARD_TERMINAL_RUN_STATES.has(loaded.run.state) || before.mtimeMs !== after.mtimeMs || after.mtimeMs !== run.stateMtimeMs) throw new Error(`Retention candidate ${run.runId} changed before deletion`);
|
|
134
|
+
}
|
|
135
|
+
async function releaseLeases(leases: SessionLease[]): Promise<void> {
|
|
136
|
+
const releases = await Promise.allSettled(leases.reverse().map((lease) => lease.release()));
|
|
137
|
+
for (const release of releases) if (release.status === "rejected") throw release.reason;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function retainTerminalRuns(options: RetentionCleanupOptions): Promise<RetentionCleanupReport> {
|
|
141
|
+
const home = resolve(options.home ?? homedir());
|
|
142
|
+
const cwd = resolve(options.cwd);
|
|
143
|
+
const now = options.now ?? Date.now();
|
|
144
|
+
if (!Number.isFinite(now)) throw new Error("Retention start time is invalid");
|
|
145
|
+
if (options.retention.olderThanDays === undefined && options.retention.maxTerminalRuns === undefined) return { candidates: [], deleted: [] };
|
|
146
|
+
const sessionIds = options.allSessions ? [...new Set([...(await listPersistedSessionIds(cwd, home)), options.sessionId])] : [options.sessionId];
|
|
147
|
+
const leases: SessionLease[] = [];
|
|
148
|
+
const cleanableSessions: string[] = [];
|
|
149
|
+
try {
|
|
150
|
+
for (const sessionId of sessionIds) {
|
|
151
|
+
if (sessionId === options.sessionId) { cleanableSessions.push(sessionId); continue; }
|
|
152
|
+
try { leases.push(await acquireSessionLease(cwd, sessionId, home)); cleanableSessions.push(sessionId); }
|
|
153
|
+
catch (error) { if (error instanceof WorkflowError && error.code === "RUN_OWNED") continue; throw error; }
|
|
154
|
+
}
|
|
155
|
+
const runs: StoredRun[] = [];
|
|
156
|
+
for (const sessionId of cleanableSessions) runs.push(...await scanSession(cwd, sessionId, home));
|
|
157
|
+
validateDependencies(runs);
|
|
158
|
+
const eligible = eligibleRuns(runs, options.retention, now);
|
|
159
|
+
const protectedRuns = new Set<string>();
|
|
160
|
+
const protect = (sessionId: string, runId: string): void => {
|
|
161
|
+
const key = runKey(sessionId, runId);
|
|
162
|
+
if (protectedRuns.has(key)) return;
|
|
163
|
+
protectedRuns.add(key);
|
|
164
|
+
for (const dependency of runs.find((run) => run.sessionId === sessionId && run.runId === runId)?.dependencies ?? []) protect(sessionId, dependency);
|
|
165
|
+
};
|
|
166
|
+
for (const run of runs) if (!eligible.has(runKey(run.sessionId, run.runId))) protect(run.sessionId, run.runId);
|
|
167
|
+
const candidates = new Set([...eligible].filter((key) => !protectedRuns.has(key)));
|
|
168
|
+
const deleted: string[] = [];
|
|
169
|
+
for (const run of cleanupOrder(runs, candidates)) {
|
|
170
|
+
await recheck(run);
|
|
171
|
+
await run.store.delete(true);
|
|
172
|
+
deleted.push(run.runId);
|
|
173
|
+
}
|
|
174
|
+
return { candidates: [...candidates].map((key) => key.slice(key.indexOf("\0") + 1)), deleted };
|
|
175
|
+
} finally {
|
|
176
|
+
await releaseLeases(leases);
|
|
177
|
+
}
|
|
178
|
+
}
|