@osolmaz/pi-workflows 0.2.0 → 0.4.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.
Files changed (152) hide show
  1. package/README.md +72 -5
  2. package/dist/builtins/catalog.d.ts +2 -0
  3. package/dist/builtins/catalog.js +22 -0
  4. package/dist/builtins/catalog.js.map +1 -0
  5. package/dist/builtins/monitor.workflow.d.ts +71 -0
  6. package/dist/builtins/monitor.workflow.js +250 -0
  7. package/dist/builtins/monitor.workflow.js.map +1 -0
  8. package/dist/controllers/conditions.d.ts +6 -0
  9. package/dist/controllers/conditions.js +68 -0
  10. package/dist/controllers/conditions.js.map +1 -0
  11. package/dist/controllers/definition.d.ts +6 -0
  12. package/dist/controllers/definition.js +45 -0
  13. package/dist/controllers/definition.js.map +1 -0
  14. package/dist/controllers/effects.d.ts +14 -0
  15. package/dist/controllers/effects.js +104 -0
  16. package/dist/controllers/effects.js.map +1 -0
  17. package/dist/controllers/errors.d.ts +12 -0
  18. package/dist/controllers/errors.js +25 -0
  19. package/dist/controllers/errors.js.map +1 -0
  20. package/dist/controllers/index.d.ts +13 -0
  21. package/dist/controllers/index.js +13 -0
  22. package/dist/controllers/index.js.map +1 -0
  23. package/dist/controllers/json.d.ts +5 -0
  24. package/dist/controllers/json.js +57 -0
  25. package/dist/controllers/json.js.map +1 -0
  26. package/dist/controllers/loader.d.ts +23 -0
  27. package/dist/controllers/loader.js +74 -0
  28. package/dist/controllers/loader.js.map +1 -0
  29. package/dist/controllers/manager.d.ts +58 -0
  30. package/dist/controllers/manager.js +399 -0
  31. package/dist/controllers/manager.js.map +1 -0
  32. package/dist/controllers/results.d.ts +5 -0
  33. package/dist/controllers/results.js +32 -0
  34. package/dist/controllers/results.js.map +1 -0
  35. package/dist/controllers/sqlite.d.ts +235 -0
  36. package/dist/controllers/sqlite.js +1087 -0
  37. package/dist/controllers/sqlite.js.map +1 -0
  38. package/dist/controllers/store.d.ts +112 -0
  39. package/dist/controllers/store.js +32 -0
  40. package/dist/controllers/store.js.map +1 -0
  41. package/dist/controllers/types.d.ts +159 -0
  42. package/dist/controllers/types.js +2 -0
  43. package/dist/controllers/types.js.map +1 -0
  44. package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
  45. package/dist/controllers/workflow-engine-scheduler.js +95 -0
  46. package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
  47. package/dist/controllers/workflows.d.ts +27 -0
  48. package/dist/controllers/workflows.js +109 -0
  49. package/dist/controllers/workflows.js.map +1 -0
  50. package/dist/extension/controller-host.d.ts +47 -0
  51. package/dist/extension/controller-host.js +110 -0
  52. package/dist/extension/controller-host.js.map +1 -0
  53. package/dist/extension/executor.d.ts +3 -0
  54. package/dist/extension/executor.js +12 -2
  55. package/dist/extension/executor.js.map +1 -1
  56. package/dist/extension/index.d.ts +7 -0
  57. package/dist/extension/index.js +1118 -106
  58. package/dist/extension/index.js.map +1 -1
  59. package/dist/extension/recorder.d.ts +6 -0
  60. package/dist/extension/recorder.js +20 -8
  61. package/dist/extension/recorder.js.map +1 -1
  62. package/dist/extension/workflow-tool.d.ts +28 -0
  63. package/dist/extension/workflow-tool.js +33 -0
  64. package/dist/extension/workflow-tool.js.map +1 -0
  65. package/dist/host/processes.d.ts +24 -0
  66. package/dist/host/processes.js +114 -0
  67. package/dist/host/processes.js.map +1 -0
  68. package/dist/host/rpc-bridge.d.ts +9 -0
  69. package/dist/host/rpc-bridge.js +39 -0
  70. package/dist/host/rpc-bridge.js.map +1 -0
  71. package/dist/host/rpc-executor.d.ts +38 -0
  72. package/dist/host/rpc-executor.js +254 -0
  73. package/dist/host/rpc-executor.js.map +1 -0
  74. package/dist/host/runner.d.ts +50 -0
  75. package/dist/host/runner.js +379 -0
  76. package/dist/host/runner.js.map +1 -0
  77. package/dist/viewer/cli.d.ts +7 -3
  78. package/dist/viewer/cli.js +150 -19
  79. package/dist/viewer/cli.js.map +1 -1
  80. package/dist/workflows/catalog.d.ts +43 -0
  81. package/dist/workflows/catalog.js +79 -0
  82. package/dist/workflows/catalog.js.map +1 -0
  83. package/dist/workflows/engine.d.ts +37 -2
  84. package/dist/workflows/engine.js +297 -29
  85. package/dist/workflows/engine.js.map +1 -1
  86. package/dist/workflows/errors.d.ts +23 -0
  87. package/dist/workflows/errors.js +38 -0
  88. package/dist/workflows/errors.js.map +1 -1
  89. package/dist/workflows/graph.js +0 -5
  90. package/dist/workflows/graph.js.map +1 -1
  91. package/dist/workflows/index.d.ts +2 -2
  92. package/dist/workflows/index.js +1 -1
  93. package/dist/workflows/index.js.map +1 -1
  94. package/dist/workflows/loader.d.ts +19 -15
  95. package/dist/workflows/loader.js +63 -19
  96. package/dist/workflows/loader.js.map +1 -1
  97. package/dist/workflows/migrate-sources.d.ts +41 -0
  98. package/dist/workflows/migrate-sources.js +129 -0
  99. package/dist/workflows/migrate-sources.js.map +1 -0
  100. package/dist/workflows/schema.js +3 -2
  101. package/dist/workflows/schema.js.map +1 -1
  102. package/dist/workflows/store.d.ts +50 -6
  103. package/dist/workflows/store.js +448 -53
  104. package/dist/workflows/store.js.map +1 -1
  105. package/dist/workflows/types.d.ts +27 -3
  106. package/docs/CONTROLLERS.md +215 -0
  107. package/docs/development.md +14 -9
  108. package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
  109. package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
  110. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
  111. package/docs/plans/2026-08-12-coordinated-workflow-timeouts-plan.md +74 -0
  112. package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +97 -0
  113. package/docs/run-bundles.md +85 -28
  114. package/docs/workflows.md +161 -17
  115. package/examples/controllers/pull-request.controller.ts +215 -0
  116. package/package.json +10 -2
  117. package/src/builtins/catalog.ts +22 -0
  118. package/src/builtins/monitor.workflow.ts +302 -0
  119. package/src/controllers/conditions.ts +110 -0
  120. package/src/controllers/definition.ts +65 -0
  121. package/src/controllers/effects.ts +123 -0
  122. package/src/controllers/errors.ts +27 -0
  123. package/src/controllers/index.ts +90 -0
  124. package/src/controllers/json.ts +62 -0
  125. package/src/controllers/loader.ts +104 -0
  126. package/src/controllers/manager.ts +533 -0
  127. package/src/controllers/results.ts +46 -0
  128. package/src/controllers/sqlite.ts +1545 -0
  129. package/src/controllers/store.ts +160 -0
  130. package/src/controllers/types.ts +183 -0
  131. package/src/controllers/workflow-engine-scheduler.ts +148 -0
  132. package/src/controllers/workflows.ts +152 -0
  133. package/src/extension/controller-host.ts +163 -0
  134. package/src/extension/executor.ts +13 -2
  135. package/src/extension/index.ts +1325 -130
  136. package/src/extension/recorder.ts +65 -36
  137. package/src/extension/workflow-tool.ts +59 -0
  138. package/src/host/processes.ts +119 -0
  139. package/src/host/rpc-bridge.ts +44 -0
  140. package/src/host/rpc-executor.ts +299 -0
  141. package/src/host/runner.ts +445 -0
  142. package/src/viewer/cli.ts +167 -21
  143. package/src/workflows/catalog.ts +135 -0
  144. package/src/workflows/engine.ts +404 -41
  145. package/src/workflows/errors.ts +45 -0
  146. package/src/workflows/graph.ts +0 -5
  147. package/src/workflows/index.ts +2 -0
  148. package/src/workflows/loader.ts +76 -22
  149. package/src/workflows/migrate-sources.ts +167 -0
  150. package/src/workflows/schema.ts +3 -2
  151. package/src/workflows/store.ts +557 -48
  152. package/src/workflows/types.ts +22 -3
@@ -0,0 +1,65 @@
1
+ import type { AnyControllerDefinition, ControllerDefinition, ControllerResource } from "./types.js";
2
+
3
+ const CONTROLLER_DEFINITION_BRAND = Symbol.for("pi-workflows.controller-definition");
4
+ const CONTROLLER_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
5
+
6
+ export function defineController<TSpec, TStatus>(
7
+ definition: ControllerDefinition<TSpec, TStatus>,
8
+ ): ControllerDefinition<TSpec, TStatus> {
9
+ assertValidControllerDefinition(definition);
10
+ if (isControllerDefinition(definition)) {
11
+ return definition;
12
+ }
13
+ Object.defineProperty(definition, CONTROLLER_DEFINITION_BRAND, {
14
+ value: true,
15
+ enumerable: false,
16
+ configurable: false,
17
+ writable: false,
18
+ });
19
+ return definition;
20
+ }
21
+
22
+ export function isControllerDefinition(value: unknown): value is AnyControllerDefinition {
23
+ return (
24
+ value !== null &&
25
+ typeof value === "object" &&
26
+ (value as Record<PropertyKey, unknown>)[CONTROLLER_DEFINITION_BRAND] === true
27
+ );
28
+ }
29
+
30
+ export function assertValidControllerDefinition<TSpec, TStatus>(
31
+ definition: ControllerDefinition<TSpec, TStatus>,
32
+ ): void {
33
+ if (definition === null || typeof definition !== "object") {
34
+ throw new Error("Invalid controller definition: expected an object");
35
+ }
36
+ if (!CONTROLLER_NAME_PATTERN.test(definition.name)) {
37
+ throw new Error(
38
+ `Invalid controller definition: name ${JSON.stringify(definition.name)} must match ${CONTROLLER_NAME_PATTERN.source}`,
39
+ );
40
+ }
41
+ if (typeof definition.initialStatus !== "function") {
42
+ throw new Error("Invalid controller definition: initialStatus must be a function");
43
+ }
44
+ if (typeof definition.reconcile !== "function") {
45
+ throw new Error("Invalid controller definition: reconcile must be a function");
46
+ }
47
+ if (
48
+ definition.timeoutMs !== undefined &&
49
+ (!Number.isSafeInteger(definition.timeoutMs) || definition.timeoutMs <= 0)
50
+ ) {
51
+ throw new Error("Invalid controller definition: timeoutMs must be a positive safe integer");
52
+ }
53
+ }
54
+
55
+ export function asAnyControllerDefinition<TSpec, TStatus>(
56
+ definition: ControllerDefinition<TSpec, TStatus>,
57
+ ): AnyControllerDefinition {
58
+ return definition as unknown as ControllerDefinition<unknown, unknown>;
59
+ }
60
+
61
+ export function asTypedResource<TSpec, TStatus>(
62
+ resource: ControllerResource,
63
+ ): ControllerResource<TSpec, TStatus> {
64
+ return resource as ControllerResource<TSpec, TStatus>;
65
+ }
@@ -0,0 +1,123 @@
1
+ import { jsonFingerprint } from "./json.js";
2
+ import type { ControllerStore } from "./store.js";
3
+ import type {
4
+ ControllerEffects,
5
+ ControllerResource,
6
+ EffectApplication,
7
+ EffectDefinition,
8
+ EffectObservation,
9
+ EffectRecord,
10
+ JsonObject,
11
+ } from "./types.js";
12
+
13
+ export class ControllerEffectService implements ControllerEffects {
14
+ private used = false;
15
+
16
+ constructor(
17
+ private readonly store: ControllerStore,
18
+ private readonly resource: ControllerResource,
19
+ private readonly signal: AbortSignal,
20
+ ) {}
21
+
22
+ async ensure<TRequest>(definition: EffectDefinition<TRequest>): Promise<EffectRecord> {
23
+ if (this.used) {
24
+ throw new Error("A reconciliation pass may ensure only one external effect");
25
+ }
26
+ this.used = true;
27
+ const requestFingerprint = jsonFingerprint(definition.request);
28
+ const reservation = this.store.reserveEffect({
29
+ key: definition.key,
30
+ resourceUid: this.resource.metadata.uid,
31
+ generation: this.resource.metadata.generation,
32
+ kind: definition.kind,
33
+ requestFingerprint,
34
+ });
35
+ if (reservation.record.state === "applied" || reservation.record.state === "rejected") {
36
+ return reservation.record;
37
+ }
38
+
39
+ if (!reservation.created) {
40
+ const observed = await this.observe(definition);
41
+ const recovered = this.applyObservation(definition.key, observed);
42
+ if (recovered !== undefined) {
43
+ return recovered;
44
+ }
45
+ }
46
+
47
+ return await this.apply(definition);
48
+ }
49
+
50
+ private async observe<TRequest>(
51
+ definition: EffectDefinition<TRequest>,
52
+ ): Promise<EffectObservation> {
53
+ try {
54
+ return await definition.observe(this.signal);
55
+ } catch (error) {
56
+ this.recordEvent("effect_observe_failed", definition.key, {
57
+ error: boundedError(error),
58
+ });
59
+ throw error;
60
+ }
61
+ }
62
+
63
+ private applyObservation(key: string, observation: EffectObservation): EffectRecord | undefined {
64
+ if (observation.state === "not_applied") {
65
+ return undefined;
66
+ }
67
+ const record = this.store.updateEffect({
68
+ resourceUid: this.resource.metadata.uid,
69
+ key,
70
+ state: observation.state === "applied" ? "applied" : "indeterminate",
71
+ ...("externalRef" in observation && observation.externalRef !== undefined
72
+ ? { externalRef: observation.externalRef }
73
+ : {}),
74
+ });
75
+ this.recordEvent(
76
+ observation.state === "applied" ? "effect_recovered" : "effect_indeterminate",
77
+ key,
78
+ );
79
+ return record;
80
+ }
81
+
82
+ private async apply<TRequest>(definition: EffectDefinition<TRequest>): Promise<EffectRecord> {
83
+ let result: EffectApplication;
84
+ try {
85
+ result = await definition.apply(this.signal);
86
+ } catch (error) {
87
+ const message = boundedError(error);
88
+ const record = this.store.updateEffect({
89
+ resourceUid: this.resource.metadata.uid,
90
+ key: definition.key,
91
+ state: "indeterminate",
92
+ error: message,
93
+ });
94
+ this.recordEvent("effect_indeterminate", definition.key, { error: message });
95
+ return record;
96
+ }
97
+ const record = this.store.updateEffect({
98
+ resourceUid: this.resource.metadata.uid,
99
+ key: definition.key,
100
+ state: result.state,
101
+ ...(result.state === "applied" && result.externalRef !== undefined
102
+ ? { externalRef: result.externalRef }
103
+ : {}),
104
+ ...(result.state !== "applied" && result.error !== undefined ? { error: result.error } : {}),
105
+ });
106
+ this.recordEvent(`effect_${result.state}`, definition.key);
107
+ return record;
108
+ }
109
+
110
+ private recordEvent(type: string, effectKey: string, extra: JsonObject = {}): void {
111
+ this.store.recordEvent({
112
+ controller: this.resource.metadata.controller,
113
+ key: this.resource.metadata.key,
114
+ type,
115
+ payload: { effectKey, ...extra },
116
+ });
117
+ }
118
+ }
119
+
120
+ function boundedError(error: unknown): string {
121
+ const message = error instanceof Error ? error.message : String(error);
122
+ return message.length <= 8_192 ? message : `${message.slice(0, 8_192)}…`;
123
+ }
@@ -0,0 +1,27 @@
1
+ export class ResourceConflictError extends Error {
2
+ constructor(controller: string, key: string) {
3
+ super(`Controller resource ${controller}/${key} changed during reconciliation`);
4
+ this.name = "ResourceConflictError";
5
+ }
6
+ }
7
+
8
+ export class ResourceNotFoundError extends Error {
9
+ constructor(controller: string, key: string) {
10
+ super(`Controller resource not found: ${controller}/${key}`);
11
+ this.name = "ResourceNotFoundError";
12
+ }
13
+ }
14
+
15
+ export class EffectRequestConflictError extends Error {
16
+ constructor(key: string) {
17
+ super(`Effect key ${JSON.stringify(key)} was reused with a different request`);
18
+ this.name = "EffectRequestConflictError";
19
+ }
20
+ }
21
+
22
+ export class WorkflowRequestConflictError extends Error {
23
+ constructor(key: string) {
24
+ super(`Workflow request key ${JSON.stringify(key)} was reused with different input`);
25
+ this.name = "WorkflowRequestConflictError";
26
+ }
27
+ }
@@ -0,0 +1,90 @@
1
+ export {
2
+ assertValidControllerDefinition,
3
+ defineController,
4
+ isControllerDefinition,
5
+ } from "./definition.js";
6
+ export {
7
+ applyStatusPatch,
8
+ conditionFalse,
9
+ conditionTrue,
10
+ conditionUnknown,
11
+ mergeConditions,
12
+ } from "./conditions.js";
13
+ export {
14
+ EffectRequestConflictError,
15
+ ResourceConflictError,
16
+ ResourceNotFoundError,
17
+ WorkflowRequestConflictError,
18
+ } from "./errors.js";
19
+ export { ControllerEffectService } from "./effects.js";
20
+ export { canonicalJson, jsonFingerprint } from "./json.js";
21
+ export { ControllerManager, type ControllerManagerOptions } from "./manager.js";
22
+ export { createResultHelpers, requeue, requeueAfter, settled } from "./results.js";
23
+ export {
24
+ SqliteControllerStore,
25
+ type RunEventRecord,
26
+ type WorkflowRunQueueRecord,
27
+ } from "./sqlite.js";
28
+ export {
29
+ CONTROLLER_STORE_SCHEMA,
30
+ controllerProjectScope,
31
+ controllerStoreBaseDir,
32
+ controllerStorePath,
33
+ projectControllerStoreBaseDir,
34
+ projectControllerStorePath,
35
+ type ControllerStore,
36
+ type EffectReservation,
37
+ type QueueItem,
38
+ type QueueRequeueOptions,
39
+ type WorkflowRecordUpdate,
40
+ type WorkflowReservation,
41
+ } from "./store.js";
42
+ export {
43
+ controllerFileStem,
44
+ controllerSearchDirs,
45
+ discoverControllers,
46
+ loadControllerFile,
47
+ loadDiscoveredControllers,
48
+ type DiscoveredController,
49
+ } from "./loader.js";
50
+ export {
51
+ WorkflowEngineScheduler,
52
+ type ResolvedChildWorkflow,
53
+ type WorkflowEngineSchedulerOptions,
54
+ } from "./workflow-engine-scheduler.js";
55
+ export {
56
+ ControllerWorkflowCoordinator,
57
+ type ControllerWorkflowScheduler,
58
+ type WorkflowSchedulerRequest,
59
+ type WorkflowSchedulerResult,
60
+ } from "./workflows.js";
61
+ export type {
62
+ AnyControllerDefinition,
63
+ ChildWorkflowRecord,
64
+ ChildWorkflowReference,
65
+ ChildWorkflowRequest,
66
+ ChildWorkflowState,
67
+ ControllerCondition,
68
+ ControllerConditionInput,
69
+ ControllerConditionStatus,
70
+ ControllerDefinition,
71
+ ControllerEffects,
72
+ ControllerEvent,
73
+ ControllerQueueClaim,
74
+ ControllerResource,
75
+ ControllerResourceRef,
76
+ ControllerResourceStatus,
77
+ ControllerStatusPatch,
78
+ ControllerWorkflows,
79
+ EffectApplication,
80
+ EffectDefinition,
81
+ EffectObservation,
82
+ EffectRecord,
83
+ EffectState,
84
+ JsonObject,
85
+ JsonPrimitive,
86
+ JsonValue,
87
+ MaybePromise,
88
+ ReconcileContext,
89
+ ReconcileResult,
90
+ } from "./types.js";
@@ -0,0 +1,62 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { JsonValue } from "./types.js";
3
+
4
+ export function assertJsonValue(value: unknown, description: string): asserts value is JsonValue {
5
+ normalizeJson(value, description, new Set());
6
+ }
7
+
8
+ export function canonicalJson(value: unknown, description = "value"): string {
9
+ return JSON.stringify(normalizeJson(value, description, new Set()));
10
+ }
11
+
12
+ export function jsonFingerprint(value: unknown): string {
13
+ return createHash("sha256").update(canonicalJson(value)).digest("hex");
14
+ }
15
+
16
+ export function parseStoredJson<T>(value: string, description: string): T {
17
+ let parsed: unknown;
18
+ try {
19
+ parsed = JSON.parse(value);
20
+ } catch (error) {
21
+ throw new Error(
22
+ `${description} contains invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
23
+ );
24
+ }
25
+ assertJsonValue(parsed, description);
26
+ return parsed as T;
27
+ }
28
+
29
+ function normalizeJson(value: unknown, path: string, seen: Set<object>): JsonValue {
30
+ if (value === null || typeof value === "string" || typeof value === "boolean") {
31
+ return value;
32
+ }
33
+ if (typeof value === "number") {
34
+ if (!Number.isFinite(value)) {
35
+ throw new Error(`${path} contains a non-finite number`);
36
+ }
37
+ return value;
38
+ }
39
+ if (typeof value !== "object") {
40
+ throw new Error(`${path} is not JSON-serializable`);
41
+ }
42
+ if (seen.has(value)) {
43
+ throw new Error(`${path} contains a cycle`);
44
+ }
45
+ seen.add(value);
46
+ try {
47
+ if (Array.isArray(value)) {
48
+ return value.map((item, index) => normalizeJson(item, `${path}[${index}]`, seen));
49
+ }
50
+ const prototype = Object.getPrototypeOf(value) as object | null;
51
+ if (prototype !== Object.prototype && prototype !== null) {
52
+ throw new Error(`${path} must contain only plain objects and arrays`);
53
+ }
54
+ const result: Record<string, JsonValue> = {};
55
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
56
+ result[key] = normalizeJson((value as Record<string, unknown>)[key], `${path}.${key}`, seen);
57
+ }
58
+ return result;
59
+ } finally {
60
+ seen.delete(value);
61
+ }
62
+ }
@@ -0,0 +1,104 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { createJiti } from "jiti";
6
+ import { isControllerDefinition } from "./definition.js";
7
+ import type { AnyControllerDefinition } from "./types.js";
8
+
9
+ const CONTROLLER_FILE_SUFFIXES = [
10
+ ".controller.ts",
11
+ ".controller.js",
12
+ ".controller.mts",
13
+ ".controller.mjs",
14
+ ];
15
+
16
+ export type DiscoveredController = {
17
+ name: string;
18
+ path: string;
19
+ source: "project" | "global";
20
+ };
21
+
22
+ export function controllerSearchDirs(options: {
23
+ cwd: string;
24
+ homeDir?: string;
25
+ }): { dir: string; source: DiscoveredController["source"] }[] {
26
+ const homeDir = options.homeDir ?? os.homedir();
27
+ return [
28
+ { dir: path.join(options.cwd, ".pi", "controllers"), source: "project" },
29
+ { dir: path.join(homeDir, ".pi", "agent", "controllers"), source: "global" },
30
+ ];
31
+ }
32
+
33
+ export function controllerFileStem(filePath: string): string {
34
+ const base = path.basename(filePath);
35
+ const suffix = CONTROLLER_FILE_SUFFIXES.find((candidate) => base.endsWith(candidate));
36
+ return suffix === undefined ? base : base.slice(0, -suffix.length);
37
+ }
38
+
39
+ const CONTROLLER_ENTRY = path.join(path.dirname(fileURLToPath(import.meta.url)), "index");
40
+ const WORKFLOW_ENTRY = path.join(
41
+ path.dirname(fileURLToPath(import.meta.url)),
42
+ "../workflows/index",
43
+ );
44
+
45
+ export async function loadControllerFile(filePath: string): Promise<AnyControllerDefinition> {
46
+ const absolutePath = path.resolve(filePath);
47
+ const jiti = createJiti(pathToFileURL(absolutePath).href, {
48
+ interopDefault: true,
49
+ moduleCache: false,
50
+ alias: {
51
+ "@osolmaz/pi-workflows/controllers": CONTROLLER_ENTRY,
52
+ "@osolmaz/pi-workflows": WORKFLOW_ENTRY,
53
+ },
54
+ });
55
+ const loaded = (await jiti.import(absolutePath, { default: true })) as unknown;
56
+ if (!isControllerDefinition(loaded)) {
57
+ throw new Error(`Controller module must default-export defineController(...): ${absolutePath}`);
58
+ }
59
+ return loaded;
60
+ }
61
+
62
+ export async function discoverControllers(options: {
63
+ cwd: string;
64
+ homeDir?: string;
65
+ }): Promise<DiscoveredController[]> {
66
+ const discovered: DiscoveredController[] = [];
67
+ const seen = new Set<string>();
68
+ for (const { dir, source } of controllerSearchDirs(options)) {
69
+ for (const filePath of await listControllerFiles(dir)) {
70
+ const name = controllerFileStem(filePath);
71
+ if (seen.has(name)) {
72
+ continue;
73
+ }
74
+ seen.add(name);
75
+ discovered.push({ name, path: filePath, source });
76
+ }
77
+ }
78
+ return discovered;
79
+ }
80
+
81
+ export async function loadDiscoveredControllers(options: {
82
+ cwd: string;
83
+ homeDir?: string;
84
+ }): Promise<AnyControllerDefinition[]> {
85
+ return await Promise.all(
86
+ (await discoverControllers(options)).map(async (entry) => await loadControllerFile(entry.path)),
87
+ );
88
+ }
89
+
90
+ async function listControllerFiles(dir: string): Promise<string[]> {
91
+ let entries: import("node:fs").Dirent[];
92
+ try {
93
+ entries = await fs.readdir(dir, { withFileTypes: true });
94
+ } catch {
95
+ return [];
96
+ }
97
+ return entries
98
+ .filter(
99
+ (entry) =>
100
+ entry.isFile() && CONTROLLER_FILE_SUFFIXES.some((suffix) => entry.name.endsWith(suffix)),
101
+ )
102
+ .map((entry) => path.join(dir, entry.name))
103
+ .sort();
104
+ }