pi-extensible-workflows 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/validation.ts CHANGED
@@ -8,9 +8,10 @@ import { Script } from "node:vm";
8
8
  import { Value } from "typebox/value";
9
9
  import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
10
10
  import { WorkflowError } from "./types.js";
11
- import type { AgentDefinition, AgentResourceExclusions, AgentResourcePolicy, CheckpointInput, JsonSchema, JsonValue, LaunchSnapshot, PreflightCapabilities, PreflightResult, ShellOptions, StaticWorkflowCall, StaticWorkflowExecution, StaticWorkflowScope, ValidatedWorkflowLaunch, WorkflowCallKind, WorkflowErrorCode, WorkflowMetadata, WorkflowSettings, WorkflowValidationContext, WorkflowValidationParameters } from "./types.js";
11
+ import type { AgentDefinition, AgentResourceExclusions, AgentResourcePolicy, CheckpointInput, JsonSchema, JsonValue, LaunchSnapshot, PreflightCapabilities, PreflightResult, ShellOptions, StaticWorkflowCall, StaticWorkflowExecution, StaticWorkflowScope, ValidatedWorkflowLaunch, WorkflowCallKind, WorkflowErrorCode, WorkflowExtensionMetadata, WorkflowMetadata, WorkflowRoleDirectoryRegistration, WorkflowSettings, WorkflowSettingsOverrides, WorkflowSettingsResolution, WorkflowSettingsSources, WorkflowValidationContext, WorkflowValidationParameters } from "./types.js";
12
12
  import type { WorkflowRegistryApi } from "./registry.js";
13
- import { deepFreeze, errorText, fail, jsonValue, mergeAgentResourceExclusions, modelAliasName, modelCapability, object, parseThinking, positiveInteger, resolveModelReference, unknownModel, validateModelAliases } from "./utils.js";
13
+ import { registeredWorkflowRoleDirectoryRegistrations } from "./registry.js";
14
+ import { annotateModelAliasError, deepFreeze, errorText, fail, jsonValue, modelAliasName, modelCapability, object, parseThinking, positiveInteger, resolveModelReference, unknownModel, validateModelAliases, validateResourcePattern } from "./utils.js";
14
15
  import { WORKFLOW_CALL_KINDS } from "./types.js";
15
16
 
16
17
  export const DEFAULT_SETTINGS: Readonly<WorkflowSettings> = Object.freeze({ concurrency: 8 });
@@ -25,10 +26,20 @@ export function validateCheckpoint(value: unknown): CheckpointInput {
25
26
  export function workflowSettingsPath(agentDir = getAgentDir()): string { return join(agentDir, ROLE_DIRECTORY, "settings.json"); }
26
27
  export function workflowProjectSettingsPath(cwd: string): string { return join(cwd, ".pi", ROLE_DIRECTORY, "settings.json"); }
27
28
  const EMPTY_AGENT_RESOURCE_EXCLUSIONS: AgentResourceExclusions = Object.freeze({ skills: [], extensions: [] });
29
+ function resourcePatternHasMagic(value: string): boolean { return /[*?\x5b\x5d{}()]/.test(value); }
28
30
  function normalizedResourcePath(value: string, settingsPath: string): string {
29
31
  let expanded = value === "~" ? homedir() : value.startsWith("~/") || value.startsWith("~\\") ? join(homedir(), value.slice(2)) : value;
30
32
  if (expanded.startsWith("file://")) expanded = fileURLToPath(expanded);
31
33
  const resolved = resolve(dirname(settingsPath), expanded);
34
+ if (expanded === "**" || expanded.startsWith("**/") || expanded.startsWith("**\\")) return expanded;
35
+ if (resourcePatternHasMagic(expanded)) {
36
+ const magicIndex = resolved.search(/[*?\x5b\x5d{}()]/);
37
+ const separatorIndex = Math.max(resolved.lastIndexOf("/", magicIndex), resolved.lastIndexOf("\\", magicIndex));
38
+ const rootBoundary = separatorIndex === 0 || (separatorIndex === 2 && /^[A-Za-z]:[\\/]/.test(resolved));
39
+ const prefix = rootBoundary ? resolved.slice(0, separatorIndex + 1) : separatorIndex >= 0 ? resolved.slice(0, separatorIndex) : resolved;
40
+ const suffix = rootBoundary ? resolved.slice(separatorIndex + 1) : separatorIndex >= 0 ? resolved.slice(separatorIndex) : "";
41
+ try { return `${realpathSync(prefix)}${suffix}`; } catch { return resolved; }
42
+ }
32
43
  try { return realpathSync(resolved); } catch { return resolved; }
33
44
  }
34
45
  function validateAgentResourceExclusions(value: unknown, settingsPath: string, errorCode: "INVALID_SETTINGS" | "INVALID_METADATA" = "INVALID_SETTINGS"): AgentResourceExclusions | undefined {
@@ -41,41 +52,74 @@ function validateAgentResourceExclusions(value: unknown, settingsPath: string, e
41
52
  const entries = value[kind];
42
53
  if (entries === undefined) continue;
43
54
  if (!Array.isArray(entries)) fail(errorCode, `${base}.${kind} must be an array`);
44
- const seen = new Set<string>();
45
55
  for (const [index, entry] of entries.entries()) {
46
56
  if (typeof entry !== "string" || !entry.trim()) fail(errorCode, `${base}.${kind}[${String(index)}] must be a non-empty string`);
47
57
  let selector = entry.trim();
48
58
  if (kind === "extensions") {
49
- try { selector = normalizedResourcePath(selector, settingsPath); } catch (error) { fail(errorCode, `${base}.${kind}[${String(index)}] must be a valid path: ${errorText(error)}`); }
59
+ const negated = selector.startsWith("!");
60
+ const body = negated ? selector.slice(1) : selector;
61
+ if (!body) fail(errorCode, `${base}.${kind}[${String(index)}] must be a valid minimatch pattern: Empty minimatch pattern ${JSON.stringify(selector)}`);
62
+ try { selector = `${negated ? "!" : ""}${normalizedResourcePath(body, settingsPath)}`; } catch (error) { fail(errorCode, `${base}.${kind}[${String(index)}] must be a valid path: ${errorText(error)}`); }
50
63
  }
51
- if (!seen.has(selector)) { seen.add(selector); normalized[kind].push(selector); }
64
+ try { validateResourcePattern(selector); } catch (error) { fail(errorCode, `${base}.${kind}[${String(index)}] must be a valid minimatch pattern: ${errorText(error)}`); }
65
+ normalized[kind].push(selector);
52
66
  }
53
67
  }
54
68
  return Object.freeze({ skills: Object.freeze(normalized.skills), extensions: Object.freeze(normalized.extensions) });
55
69
  }
56
- export function loadSettings(path = workflowSettingsPath()): Readonly<WorkflowSettings> {
70
+ function parseSettings(path: string, partial: false): Readonly<WorkflowSettings>;
71
+ function parseSettings(path: string, partial: true): Readonly<WorkflowSettingsOverrides>;
72
+ function parseSettings(path: string, partial: boolean): Readonly<WorkflowSettings | WorkflowSettingsOverrides> {
57
73
  let parsed: unknown;
58
74
  try { parsed = JSON.parse(readFileSync(path, "utf8")); }
59
75
  catch (error) {
60
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return DEFAULT_SETTINGS;
76
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return partial ? Object.freeze({}) : DEFAULT_SETTINGS;
61
77
  fail("CONFIG_ERROR", `Invalid workflow settings JSON at ${path}: ${errorText(error)}`);
62
78
  }
63
79
  if (!object(parsed)) fail("INVALID_SETTINGS", `Workflow settings at ${path} must be an object`);
64
80
  const allowed = new Set(["concurrency", "modelAliases", "disabledAgentResources"]);
65
81
  const unknown = Object.keys(parsed).find((key) => !allowed.has(key));
66
82
  if (unknown) fail("INVALID_SETTINGS", `Unknown workflow setting at ${path}: ${unknown}`);
67
- const concurrency = parsed.concurrency === undefined ? DEFAULT_SETTINGS.concurrency : parsed.concurrency;
68
- if (!positiveInteger(concurrency) || concurrency > 16) fail("INVALID_SETTINGS", `${path}.concurrency must be an integer from 1 to 16`);
83
+ const concurrency = parsed.concurrency === undefined ? (partial ? undefined : DEFAULT_SETTINGS.concurrency) : parsed.concurrency;
84
+ if (concurrency !== undefined && (!positiveInteger(concurrency) || concurrency > 16)) fail("INVALID_SETTINGS", `${path}.concurrency must be an integer from 1 to 16`);
69
85
  const modelAliases = parsed.modelAliases === undefined ? undefined : validateModelAliases(parsed.modelAliases, path);
70
86
  const disabledAgentResources = validateAgentResourceExclusions(parsed.disabledAgentResources, path);
71
- return Object.freeze({ concurrency, ...(modelAliases ? { modelAliases } : {}), ...(disabledAgentResources ? { disabledAgentResources } : {}) });
87
+ return Object.freeze({ ...(concurrency === undefined ? {} : { concurrency }), ...(modelAliases === undefined ? {} : { modelAliases }), ...(disabledAgentResources === undefined ? {} : { disabledAgentResources }) });
72
88
  }
73
- export function resolveAgentResourcePolicy(cwd: string, projectTrusted: boolean, globalSettingsPath = workflowSettingsPath()): AgentResourcePolicy {
89
+ export function loadSettings(path = workflowSettingsPath()): Readonly<WorkflowSettings> { return parseSettings(path, false); }
90
+ export function loadSettingsOverrides(path: string): Readonly<WorkflowSettingsOverrides> { return parseSettings(path, true); }
91
+ export function resolveWorkflowSettings(cwd: string, projectTrusted: boolean, globalSettingsPath = workflowSettingsPath()): WorkflowSettingsResolution {
74
92
  const projectSettingsPath = workflowProjectSettingsPath(cwd);
75
- const global = loadSettings(globalSettingsPath).disabledAgentResources ?? EMPTY_AGENT_RESOURCE_EXCLUSIONS;
76
- const project = projectTrusted ? loadSettings(projectSettingsPath).disabledAgentResources ?? EMPTY_AGENT_RESOURCE_EXCLUSIONS : EMPTY_AGENT_RESOURCE_EXCLUSIONS;
77
- const effective = mergeAgentResourceExclusions(global, project);
78
- return { globalSettingsPath, projectSettingsPath, projectTrusted, global, project, effective, unmatchedSkills: [], unmatchedExtensions: [] };
93
+ const global = loadSettings(globalSettingsPath);
94
+ const project: Readonly<WorkflowSettingsOverrides> = projectTrusted ? loadSettingsOverrides(projectSettingsPath) : Object.freeze({});
95
+ const projectHas = (key: keyof WorkflowSettingsOverrides): boolean => Object.prototype.hasOwnProperty.call(project, key);
96
+ const sources: WorkflowSettingsSources = {
97
+ concurrency: projectHas("concurrency") ? projectSettingsPath : globalSettingsPath,
98
+ modelAliases: projectHas("modelAliases") ? projectSettingsPath : globalSettingsPath,
99
+ disabledAgentResources: projectHas("disabledAgentResources") ? projectSettingsPath : globalSettingsPath,
100
+ };
101
+ const effective = Object.freeze({
102
+ concurrency: project.concurrency ?? global.concurrency,
103
+ ...(projectHas("modelAliases") ? { modelAliases: project.modelAliases } : global.modelAliases === undefined ? {} : { modelAliases: global.modelAliases }),
104
+ ...(projectHas("disabledAgentResources") ? { disabledAgentResources: project.disabledAgentResources } : global.disabledAgentResources === undefined ? {} : { disabledAgentResources: global.disabledAgentResources }),
105
+ });
106
+ return { globalSettingsPath, projectSettingsPath, projectTrusted, global, project, effective, sources };
107
+ }
108
+ export function validateModelAliasAvailability(aliases: Readonly<Record<string, string>>, names: readonly string[], availableModels: ReadonlySet<string>, knownModels: ReadonlySet<string>, settingsPath?: string): void {
109
+ for (const name of names) {
110
+ try {
111
+ const target = modelCapability(name, aliases, knownModels, settingsPath);
112
+ if (!availableModels.has(target)) unknownModel(name, target, settingsPath);
113
+ } catch (error) { throw annotateModelAliasError(error, name); }
114
+ }
115
+ }
116
+ export function resolveAgentResourcePolicy(cwd: string, projectTrusted: boolean, globalSettingsPath = workflowSettingsPath()): AgentResourcePolicy {
117
+ const resolved = resolveWorkflowSettings(cwd, projectTrusted, globalSettingsPath);
118
+ const empty = EMPTY_AGENT_RESOURCE_EXCLUSIONS;
119
+ const global = resolved.global.disabledAgentResources ?? empty;
120
+ const project = resolved.project.disabledAgentResources ?? empty;
121
+ const effective = resolved.effective.disabledAgentResources ?? empty;
122
+ return { globalSettingsPath: resolved.globalSettingsPath, projectSettingsPath: resolved.projectSettingsPath, projectTrusted, global, project, effective, unmatchedSkills: [], unmatchedExtensions: [] };
79
123
  }
80
124
  export function saveModelAliases(path = workflowSettingsPath(), aliases: Readonly<Record<string, string>> = {}): void {
81
125
  const normalized = validateModelAliases(aliases, path);
@@ -135,23 +179,51 @@ function projectRoleDirectories(root: string): readonly string[] {
135
179
  return [join(root, ROLE_DIRECTORY, "roles")];
136
180
  }
137
181
 
138
- function readAgentDefinitions(dir: string): Record<string, AgentDefinition> {
139
- try {
140
- return Object.fromEntries(readdirSync(dir, { withFileTypes: true })
141
- .filter((entry) => entry.isFile() && extname(entry.name) === ".md")
142
- .map((entry) => { const path = join(dir, entry.name); return [basename(entry.name, ".md"), parseRoleMarkdown(readFileSync(path, "utf8"), true, path)]; }));
143
- } catch (error) {
144
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
145
- throw error;
146
- }
182
+ type RoleDirectorySource = { path: string; extension?: WorkflowExtensionMetadata };
183
+ export type WorkflowRoleDirectoryInput = string | WorkflowRoleDirectoryRegistration;
184
+ type RoleFile = { name: string; path: string; source: RoleDirectorySource };
185
+ function roleDirectorySources(dirs: readonly WorkflowRoleDirectoryInput[]): RoleDirectorySource[] {
186
+ const seen = new Set<string>();
187
+ return dirs.flatMap((value) => {
188
+ const source = typeof value === "string" ? { path: value } : { path: value.path, extension: value.extension };
189
+ if (seen.has(source.path)) return [];
190
+ seen.add(source.path);
191
+ return [source];
192
+ });
147
193
  }
148
-
149
- function readRoleDefinitions(dirs: readonly string[]): Record<string, AgentDefinition> {
150
- return Object.fromEntries(dirs.flatMap((dir) => Object.entries(readAgentDefinitions(dir))));
194
+ function roleDirectoryLabel(source: RoleDirectorySource, extension = true): string {
195
+ return source.extension ? `Extension "${source.extension.headline}" (${source.extension.version})` : extension ? "Registered workflow extension" : "Standard workflow";
196
+ }
197
+ function scanRoleFiles(dirs: readonly WorkflowRoleDirectoryInput[], extension: boolean): RoleFile[] {
198
+ const files: RoleFile[] = [];
199
+ for (const source of roleDirectorySources(dirs)) {
200
+ let entries: import("node:fs").Dirent[];
201
+ try { entries = readdirSync(source.path, { withFileTypes: true }); }
202
+ catch (error) {
203
+ if (!extension && (error as NodeJS.ErrnoException).code === "ENOENT") continue;
204
+ fail("INVALID_METADATA", `${roleDirectoryLabel(source, extension)} role directory "${source.path}" could not be scanned: ${errorText(error)}`);
205
+ }
206
+ for (const entry of entries) if (entry.isFile() && extname(entry.name) === ".md") files.push({ name: basename(entry.name, ".md"), path: join(source.path, entry.name), source });
207
+ }
208
+ return files.sort((left, right) => left.name.localeCompare(right.name) || left.path.localeCompare(right.path));
209
+ }
210
+ function readRoleDefinitions(dirs: readonly WorkflowRoleDirectoryInput[], extension = false): Record<string, AgentDefinition> {
211
+ const files = scanRoleFiles(dirs, extension);
212
+ if (extension) {
213
+ const byName = new Map<string, RoleFile[]>();
214
+ for (const file of files) byName.set(file.name, [...(byName.get(file.name) ?? []), file]);
215
+ for (const [name, matches] of byName) if (matches.length > 1) fail("INVALID_METADATA", `Duplicate extension role "${name}": ${matches.map(({ path, source }) => `${roleDirectoryLabel(source)} role directory "${source.path}" (${path})`).join("; ")}`);
216
+ }
217
+ return Object.fromEntries(files.map(({ name, path, source }) => {
218
+ try { return [name, parseRoleMarkdown(readFileSync(path, "utf8"), true, path)]; }
219
+ catch (error) {
220
+ if (extension) fail("INVALID_METADATA", `${roleDirectoryLabel(source)} role directory "${source.path}" contains invalid role "${name}" at "${path}": ${errorText(error)}`);
221
+ throw error;
222
+ }
223
+ }));
151
224
  }
152
-
153
- export function loadAgentDefinitions(cwd: string, agentDir = getAgentDir(), projectTrusted = true): Readonly<Record<string, AgentDefinition>> {
154
- return deepFreeze({ ...readRoleDefinitions(workflowRoleDirectories(agentDir)), ...(projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(cwd, ".pi"))) : {}) });
225
+ export function loadAgentDefinitions(cwd: string, agentDir = getAgentDir(), projectTrusted = true, extensionRoleDirectories: readonly WorkflowRoleDirectoryInput[] = registeredWorkflowRoleDirectoryRegistrations()): Readonly<Record<string, AgentDefinition>> {
226
+ return deepFreeze({ ...readRoleDefinitions(extensionRoleDirectories, true), ...readRoleDefinitions(workflowRoleDirectories(agentDir)), ...(projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(cwd, ".pi"))) : {}) });
155
227
  }
156
228
  function validateRolePolicies(definitions: Readonly<Record<string, AgentDefinition>>, roles: readonly string[], availableModels: ReadonlySet<string>, rootTools: ReadonlySet<string>, aliases: Readonly<Record<string, string>> = {}, knownModels = availableModels, settingsPath?: string): void {
157
229
  for (const role of roles) {
@@ -590,7 +662,7 @@ export function validateWorkflowLaunchWithRegistry(params: WorkflowValidationPar
590
662
  const script = functionName !== undefined && fn ? functionLaunchScript(functionName) : typeof params.script === "string" && params.script.trim() ? params.script : "";
591
663
  if (!script) fail("INVALID_SYNTAX", "Provide script or registered function");
592
664
  const metadata = validateWorkflowMetadata({ name: workflowName, ...(typeof params.description === "string" ? { description: params.description } : fn?.description ? { description: fn.description } : {}) });
593
- const globalAgentDefinitions = loadAgentDefinitions(context.cwd, context.agentDir, false);
665
+ const globalAgentDefinitions = loadAgentDefinitions(context.cwd, context.agentDir, false, registry && typeof registry.roleDirectoryRegistrations === "function" ? registry.roleDirectoryRegistrations() : registry && typeof registry.roleDirectories === "function" ? registry.roleDirectories() : undefined);
594
666
  const projectAgentDefinitions = context.projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(context.cwd, ".pi"))) : {};
595
667
  const agentDefinitions = deepFreeze({ ...globalAgentDefinitions, ...projectAgentDefinitions });
596
668
  const aliases = context.modelAliases ?? {};