@twelvehart/orcats 0.0.0-seed.0 → 0.2.3
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/LICENSE +48 -0
- package/NOTICE +5 -0
- package/README.md +399 -0
- package/bin/orcats +4 -0
- package/dist/backends/acp-client.d.ts +74 -0
- package/dist/backends/acp-client.d.ts.map +1 -0
- package/dist/backends/acp-run.d.ts +31 -0
- package/dist/backends/acp-run.d.ts.map +1 -0
- package/dist/backends/claude-run.d.ts +35 -0
- package/dist/backends/claude-run.d.ts.map +1 -0
- package/dist/backends/claude-stream-json.d.ts +15 -0
- package/dist/backends/claude-stream-json.d.ts.map +1 -0
- package/dist/backends/codex-jsonl.d.ts +34 -0
- package/dist/backends/codex-jsonl.d.ts.map +1 -0
- package/dist/backends/codex-run.d.ts +40 -0
- package/dist/backends/codex-run.d.ts.map +1 -0
- package/dist/backends/codex.d.ts +5 -0
- package/dist/backends/codex.d.ts.map +1 -0
- package/dist/backends/conversation-config.d.ts +12 -0
- package/dist/backends/conversation-config.d.ts.map +1 -0
- package/dist/backends/index.d.ts +14 -0
- package/dist/backends/index.d.ts.map +1 -0
- package/dist/backends/opencode-run.d.ts +48 -0
- package/dist/backends/opencode-run.d.ts.map +1 -0
- package/dist/backends/opencode-sse.d.ts +35 -0
- package/dist/backends/opencode-sse.d.ts.map +1 -0
- package/dist/backends/pi-rpc.d.ts +21 -0
- package/dist/backends/pi-rpc.d.ts.map +1 -0
- package/dist/backends/pi-run.d.ts +21 -0
- package/dist/backends/pi-run.d.ts.map +1 -0
- package/dist/backends/select.d.ts +17 -0
- package/dist/backends/select.d.ts.map +1 -0
- package/dist/backends/subprocess-run.d.ts +68 -0
- package/dist/backends/subprocess-run.d.ts.map +1 -0
- package/dist/backends/types.d.ts +17 -0
- package/dist/backends/types.d.ts.map +1 -0
- package/dist/backends/unsupported.d.ts +9 -0
- package/dist/backends/unsupported.d.ts.map +1 -0
- package/dist/baseline/index.d.ts +56 -0
- package/dist/baseline/index.d.ts.map +1 -0
- package/dist/cli/args.d.ts +22 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/embedded.d.ts +7 -0
- package/dist/cli/embedded.d.ts.map +1 -0
- package/dist/cli/main.d.ts +6 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/version.d.ts +2 -0
- package/dist/cli/version.d.ts.map +1 -0
- package/dist/conversation/ask-user.d.ts +17 -0
- package/dist/conversation/ask-user.d.ts.map +1 -0
- package/dist/conversation/conversation.d.ts +46 -0
- package/dist/conversation/conversation.d.ts.map +1 -0
- package/dist/conversation/harness.d.ts +14 -0
- package/dist/conversation/harness.d.ts.map +1 -0
- package/dist/conversation/index.d.ts +5 -0
- package/dist/conversation/index.d.ts.map +1 -0
- package/dist/conversation/queue.d.ts +15 -0
- package/dist/conversation/queue.d.ts.map +1 -0
- package/dist/flow/accessors.d.ts +13 -0
- package/dist/flow/accessors.d.ts.map +1 -0
- package/dist/flow/approvals.d.ts +6 -0
- package/dist/flow/approvals.d.ts.map +1 -0
- package/dist/flow/args.d.ts +4 -0
- package/dist/flow/args.d.ts.map +1 -0
- package/dist/flow/context.d.ts +36 -0
- package/dist/flow/context.d.ts.map +1 -0
- package/dist/flow/index.d.ts +5 -0
- package/dist/flow/index.d.ts.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/loop/builder/index.d.ts +17 -0
- package/dist/loop/builder/index.d.ts.map +1 -0
- package/dist/loop/builder/types.d.ts +97 -0
- package/dist/loop/builder/types.d.ts.map +1 -0
- package/dist/loop/context/compaction.d.ts +38 -0
- package/dist/loop/context/compaction.d.ts.map +1 -0
- package/dist/loop/context/index.d.ts +4 -0
- package/dist/loop/context/index.d.ts.map +1 -0
- package/dist/loop/context/offload.d.ts +34 -0
- package/dist/loop/context/offload.d.ts.map +1 -0
- package/dist/loop/context/types.d.ts +13 -0
- package/dist/loop/context/types.d.ts.map +1 -0
- package/dist/loop/engine/bridge.d.ts +21 -0
- package/dist/loop/engine/bridge.d.ts.map +1 -0
- package/dist/loop/engine/index.d.ts +73 -0
- package/dist/loop/engine/index.d.ts.map +1 -0
- package/dist/loop/execution.d.ts +68 -0
- package/dist/loop/execution.d.ts.map +1 -0
- package/dist/loop/fanout.d.ts +148 -0
- package/dist/loop/fanout.d.ts.map +1 -0
- package/dist/loop/firing.d.ts +54 -0
- package/dist/loop/firing.d.ts.map +1 -0
- package/dist/loop/graph/analyze.d.ts +8 -0
- package/dist/loop/graph/analyze.d.ts.map +1 -0
- package/dist/loop/graph/index.d.ts +3 -0
- package/dist/loop/graph/index.d.ts.map +1 -0
- package/dist/loop/graph/types.d.ts +35 -0
- package/dist/loop/graph/types.d.ts.map +1 -0
- package/dist/loop/index.d.ts +7 -0
- package/dist/loop/index.d.ts.map +1 -0
- package/dist/loop/io/index.d.ts +4 -0
- package/dist/loop/io/index.d.ts.map +1 -0
- package/dist/loop/io/linear.d.ts +142 -0
- package/dist/loop/io/linear.d.ts.map +1 -0
- package/dist/loop/io/sink.d.ts +47 -0
- package/dist/loop/io/sink.d.ts.map +1 -0
- package/dist/loop/io/source.d.ts +64 -0
- package/dist/loop/io/source.d.ts.map +1 -0
- package/dist/loop/serve.d.ts +97 -0
- package/dist/loop/serve.d.ts.map +1 -0
- package/dist/loop/state/index.d.ts +5 -0
- package/dist/loop/state/index.d.ts.map +1 -0
- package/dist/loop/state/manifest.d.ts +36 -0
- package/dist/loop/state/manifest.d.ts.map +1 -0
- package/dist/loop/state/port.d.ts +33 -0
- package/dist/loop/state/port.d.ts.map +1 -0
- package/dist/loop/state/snapshot.d.ts +15 -0
- package/dist/loop/state/snapshot.d.ts.map +1 -0
- package/dist/loop/state/sqlite.d.ts +31 -0
- package/dist/loop/state/sqlite.d.ts.map +1 -0
- package/dist/loop/termination-contract.d.ts +28 -0
- package/dist/loop/termination-contract.d.ts.map +1 -0
- package/dist/loop/termination.d.ts +33 -0
- package/dist/loop/termination.d.ts.map +1 -0
- package/dist/model/backend-config.d.ts +30 -0
- package/dist/model/backend-config.d.ts.map +1 -0
- package/dist/model/brand.d.ts +9 -0
- package/dist/model/brand.d.ts.map +1 -0
- package/dist/model/errors.d.ts +11 -0
- package/dist/model/errors.d.ts.map +1 -0
- package/dist/model/index.d.ts +8 -0
- package/dist/model/index.d.ts.map +1 -0
- package/dist/model/json-schema.d.ts +4 -0
- package/dist/model/json-schema.d.ts.map +1 -0
- package/dist/model/result.d.ts +3 -0
- package/dist/model/result.d.ts.map +1 -0
- package/dist/model/schemas.d.ts +435 -0
- package/dist/model/schemas.d.ts.map +1 -0
- package/dist/model/structured.d.ts +9 -0
- package/dist/model/structured.d.ts.map +1 -0
- package/dist/monitor/index.d.ts +139 -0
- package/dist/monitor/index.d.ts.map +1 -0
- package/dist/plan/index.d.ts +3 -0
- package/dist/plan/index.d.ts.map +1 -0
- package/dist/plan/persistent.d.ts +23 -0
- package/dist/plan/persistent.d.ts.map +1 -0
- package/dist/plan/verdict.d.ts +9 -0
- package/dist/plan/verdict.d.ts.map +1 -0
- package/dist/review/index.d.ts +4 -0
- package/dist/review/index.d.ts.map +1 -0
- package/dist/review/loop.d.ts +117 -0
- package/dist/review/loop.d.ts.map +1 -0
- package/dist/review/reviewers.d.ts +13 -0
- package/dist/review/reviewers.d.ts.map +1 -0
- package/dist/review/tool.d.ts +20 -0
- package/dist/review/tool.d.ts.map +1 -0
- package/dist/run-output/index.d.ts +112 -0
- package/dist/run-output/index.d.ts.map +1 -0
- package/dist/runner/index.d.ts +2 -0
- package/dist/runner/index.d.ts.map +1 -0
- package/dist/runner/typecheck.d.ts +23 -0
- package/dist/runner/typecheck.d.ts.map +1 -0
- package/dist/test-utils/fakes.d.ts +76 -0
- package/dist/test-utils/fakes.d.ts.map +1 -0
- package/dist/test-utils/fixtures.d.ts +4 -0
- package/dist/test-utils/fixtures.d.ts.map +1 -0
- package/dist/test-utils/index.d.ts +4 -0
- package/dist/test-utils/index.d.ts.map +1 -0
- package/dist/test-utils/tier2.d.ts +8 -0
- package/dist/test-utils/tier2.d.ts.map +1 -0
- package/dist/tools/fs.d.ts +11 -0
- package/dist/tools/fs.d.ts.map +1 -0
- package/dist/tools/git.d.ts +10 -0
- package/dist/tools/git.d.ts.map +1 -0
- package/dist/tools/github.d.ts +13 -0
- package/dist/tools/github.d.ts.map +1 -0
- package/dist/tools/index.d.ts +7 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/linear.d.ts +117 -0
- package/dist/tools/linear.d.ts.map +1 -0
- package/dist/tools/process.d.ts +40 -0
- package/dist/tools/process.d.ts.map +1 -0
- package/dist/tools/terminal.d.ts +23 -0
- package/dist/tools/terminal.d.ts.map +1 -0
- package/package.json +89 -9
- package/src/backends/acp-client.ts +324 -0
- package/src/backends/acp-run.ts +636 -0
- package/src/backends/claude-run.ts +226 -0
- package/src/backends/claude-stream-json.ts +273 -0
- package/src/backends/codex-jsonl.ts +339 -0
- package/src/backends/codex-run.ts +177 -0
- package/src/backends/codex.ts +84 -0
- package/src/backends/conversation-config.ts +26 -0
- package/src/backends/index.ts +13 -0
- package/src/backends/opencode-run.ts +432 -0
- package/src/backends/opencode-sse.ts +360 -0
- package/src/backends/pi-rpc.ts +242 -0
- package/src/backends/pi-run.ts +154 -0
- package/src/backends/select.ts +77 -0
- package/src/backends/subprocess-run.ts +305 -0
- package/src/backends/types.ts +29 -0
- package/src/backends/unsupported.ts +48 -0
- package/src/baseline/index.ts +437 -0
- package/src/cli/args.ts +109 -0
- package/src/cli/embedded.ts +206 -0
- package/src/cli/main.ts +332 -0
- package/src/cli/version.ts +1 -0
- package/src/conversation/ask-user.ts +105 -0
- package/src/conversation/conversation.ts +109 -0
- package/src/conversation/harness.ts +39 -0
- package/src/conversation/index.ts +4 -0
- package/src/conversation/queue.ts +86 -0
- package/src/flow/accessors.ts +43 -0
- package/src/flow/approvals.ts +10 -0
- package/src/flow/args.ts +26 -0
- package/src/flow/context.ts +106 -0
- package/src/flow/index.ts +4 -0
- package/src/index.ts +17 -0
- package/src/loop/builder/index.ts +346 -0
- package/src/loop/builder/types.ts +131 -0
- package/src/loop/context/compaction.ts +175 -0
- package/src/loop/context/index.ts +3 -0
- package/src/loop/context/offload.ts +79 -0
- package/src/loop/context/types.ts +17 -0
- package/src/loop/engine/bridge.ts +61 -0
- package/src/loop/engine/index.ts +166 -0
- package/src/loop/execution.ts +453 -0
- package/src/loop/fanout.ts +444 -0
- package/src/loop/firing.ts +188 -0
- package/src/loop/graph/analyze.ts +128 -0
- package/src/loop/graph/index.ts +2 -0
- package/src/loop/graph/types.ts +45 -0
- package/src/loop/index.ts +11 -0
- package/src/loop/io/index.ts +3 -0
- package/src/loop/io/linear.ts +714 -0
- package/src/loop/io/sink.ts +170 -0
- package/src/loop/io/source.ts +255 -0
- package/src/loop/serve.ts +307 -0
- package/src/loop/state/index.ts +4 -0
- package/src/loop/state/manifest.ts +56 -0
- package/src/loop/state/port.ts +43 -0
- package/src/loop/state/snapshot.ts +129 -0
- package/src/loop/state/sqlite.ts +265 -0
- package/src/loop/termination-contract.ts +89 -0
- package/src/loop/termination.ts +156 -0
- package/src/model/backend-config.ts +34 -0
- package/src/model/brand.ts +11 -0
- package/src/model/errors.ts +28 -0
- package/src/model/index.ts +7 -0
- package/src/model/json-schema.ts +14 -0
- package/src/model/result.ts +28 -0
- package/src/model/schemas.ts +218 -0
- package/src/model/structured.ts +26 -0
- package/src/monitor/index.ts +367 -0
- package/src/plan/index.ts +2 -0
- package/src/plan/persistent.ts +98 -0
- package/src/plan/verdict.ts +3 -0
- package/src/review/index.ts +3 -0
- package/src/review/loop.ts +365 -0
- package/src/review/reviewers.ts +92 -0
- package/src/review/tool.ts +41 -0
- package/src/run-output/index.ts +319 -0
- package/src/runner/index.ts +1 -0
- package/src/runner/typecheck.ts +72 -0
- package/src/test-utils/fakes.ts +260 -0
- package/src/test-utils/fixtures.ts +33 -0
- package/src/test-utils/index.ts +3 -0
- package/src/test-utils/tier2.ts +12 -0
- package/src/tools/fs.ts +44 -0
- package/src/tools/git.ts +34 -0
- package/src/tools/github.ts +25 -0
- package/src/tools/index.ts +6 -0
- package/src/tools/linear.ts +428 -0
- package/src/tools/process.ts +156 -0
- package/src/tools/terminal.ts +116 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { err, ok, type Result } from "neverthrow";
|
|
5
|
+
|
|
6
|
+
import { ioFailed, type RuntimeError } from "../model/index.ts";
|
|
7
|
+
import type { LoopOutcome, LoopRunError } from "./builder/index.ts";
|
|
8
|
+
import { createLoopChildSpec, spawnLoopChild, type ChildHandle, type ChildSpawner } from "./firing.ts";
|
|
9
|
+
import type { Sink, Source } from "./io/index.ts";
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
exitCodeForRun,
|
|
13
|
+
exitCodeForStop,
|
|
14
|
+
} from "./firing.ts";
|
|
15
|
+
export type {
|
|
16
|
+
ChildHandle,
|
|
17
|
+
ChildSpec,
|
|
18
|
+
ChildSpawner,
|
|
19
|
+
} from "./firing.ts";
|
|
20
|
+
|
|
21
|
+
// L11 loop distribution surface (spec distribution; design D8). A LoopDefinition binds a loop's
|
|
22
|
+
// trigger Source and output Sink to a one-shot runner; `defineLoop` is the authoring front door.
|
|
23
|
+
// Discovery is import-only (registers, never fires), `serve` is a thin supervisor that spawns an
|
|
24
|
+
// ephemeral child per trigger firing. No Effect type appears here (facade gate, design D2).
|
|
25
|
+
|
|
26
|
+
/** A registered loop: its trigger `Source`, output `Sink`, and a one-shot runner. */
|
|
27
|
+
export interface LoopDefinition<E = unknown, A = unknown, S = unknown> {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly source: Source<E>;
|
|
30
|
+
readonly sink: Sink<A>;
|
|
31
|
+
/** Run the loop once for a trigger event, emit its output to the sink, resolve to the stop outcome. */
|
|
32
|
+
run(event: E): Promise<Result<LoopOutcome<S>, LoopRunError>>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What one loop firing produces: the stop outcome plus the value to emit to the sink. */
|
|
36
|
+
export interface LoopEmission<A = unknown, S = unknown> {
|
|
37
|
+
readonly outcome: LoopOutcome<S>;
|
|
38
|
+
readonly output: A;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Authoring config for `defineLoop`. `onTrigger` runs the loop; the sink emit is wired for you. */
|
|
42
|
+
export interface LoopConfig<E = unknown, A = unknown, S = unknown> {
|
|
43
|
+
readonly name: string;
|
|
44
|
+
readonly source: Source<E>;
|
|
45
|
+
readonly sink: Sink<A>;
|
|
46
|
+
/** Execute the loop once for `event`; return the stop outcome and the value to emit. */
|
|
47
|
+
readonly onTrigger: (event: E) => Promise<Result<LoopEmission<A, S>, LoopRunError>>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Package a loop with its Source + Sink into a discoverable definition. A loop module exports the
|
|
52
|
+
* result; importing the module only registers it (no Source fires, no backend runs, no Sink emits)
|
|
53
|
+
* so `orcats loops` discovery is side-effect-free. `run` folds the sink emit into one Result so the
|
|
54
|
+
* CLI and the `serve` child share identical output behavior.
|
|
55
|
+
*/
|
|
56
|
+
export function defineLoop<E = unknown, A = unknown, S = unknown>(
|
|
57
|
+
config: LoopConfig<E, A, S>,
|
|
58
|
+
): LoopDefinition<E, A, S> {
|
|
59
|
+
return {
|
|
60
|
+
name: config.name,
|
|
61
|
+
source: config.source,
|
|
62
|
+
sink: config.sink,
|
|
63
|
+
async run(event) {
|
|
64
|
+
const fired = await config.onTrigger(event);
|
|
65
|
+
if (fired.isErr()) {
|
|
66
|
+
return err(fired.error);
|
|
67
|
+
}
|
|
68
|
+
const emitted = await config.sink.emit(fired.value.output);
|
|
69
|
+
if (emitted.isErr()) {
|
|
70
|
+
return err(emitted.error);
|
|
71
|
+
}
|
|
72
|
+
return ok(fired.value.outcome);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Structural guard: an object with a name, a `run`, and Source/Sink-shaped seams. */
|
|
78
|
+
export function isLoopDefinition(value: unknown): value is LoopDefinition {
|
|
79
|
+
if (typeof value !== "object" || value === null) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
const candidate = value as Record<string, unknown>;
|
|
83
|
+
return (
|
|
84
|
+
typeof candidate.name === "string" &&
|
|
85
|
+
typeof candidate.run === "function" &&
|
|
86
|
+
isSeam(candidate.source) &&
|
|
87
|
+
isSeam(candidate.sink)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isSeam(value: unknown): boolean {
|
|
92
|
+
return typeof value === "object" && value !== null && typeof (value as { kind?: unknown }).kind === "string";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --- Discovery: import loop modules and collect their exported definitions (import-only). ---
|
|
96
|
+
|
|
97
|
+
/** Imports a module by absolute path, returning its export namespace. */
|
|
98
|
+
export type ModuleImporter = (absolutePath: string) => Promise<Record<string, unknown>>;
|
|
99
|
+
/** Lists candidate loop-module files under a directory. */
|
|
100
|
+
export type FileLister = (dir: string) => Promise<string[]>;
|
|
101
|
+
|
|
102
|
+
const defaultLister: FileLister = async (dir) => {
|
|
103
|
+
if (!existsSync(dir)) {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
const glob = new Bun.Glob("**/*.ts");
|
|
107
|
+
const files: string[] = [];
|
|
108
|
+
for await (const entry of glob.scan({ cwd: dir, absolute: true })) {
|
|
109
|
+
files.push(entry);
|
|
110
|
+
}
|
|
111
|
+
return files;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const defaultImporter: ModuleImporter = async (absolutePath) => {
|
|
115
|
+
const module: unknown = await import(pathToFileURL(absolutePath).href);
|
|
116
|
+
return module as Record<string, unknown>;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** Scan imported module namespaces for exported LoopDefinitions, keyed by loop name. */
|
|
120
|
+
export function collectDefinitions(modules: Iterable<Record<string, unknown>>): Map<string, LoopDefinition> {
|
|
121
|
+
const found = new Map<string, LoopDefinition>();
|
|
122
|
+
for (const module of modules) {
|
|
123
|
+
for (const exported of Object.values(module)) {
|
|
124
|
+
if (isLoopDefinition(exported)) {
|
|
125
|
+
found.set(exported.name, exported);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return found;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface DiscoverOptions {
|
|
133
|
+
readonly dir: string;
|
|
134
|
+
readonly list?: FileLister;
|
|
135
|
+
readonly import?: ModuleImporter;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Discover loop definitions under `dir` (by convention `.orca/loops`, holding export-only loop
|
|
140
|
+
* modules — NOT self-executing flow scripts). Import-only: constructing a definition via
|
|
141
|
+
* `defineLoop` fires no Source, backend, or Sink, so discovery has no side effects.
|
|
142
|
+
*/
|
|
143
|
+
export async function discoverLoops(
|
|
144
|
+
options: DiscoverOptions,
|
|
145
|
+
): Promise<Result<Map<string, LoopDefinition>, RuntimeError>> {
|
|
146
|
+
const list = options.list ?? defaultLister;
|
|
147
|
+
const load = options.import ?? defaultImporter;
|
|
148
|
+
let files: string[];
|
|
149
|
+
try {
|
|
150
|
+
files = await list(options.dir);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return err(ioFailed("source", "discover", String(error)));
|
|
153
|
+
}
|
|
154
|
+
const modules: Record<string, unknown>[] = [];
|
|
155
|
+
for (const file of files) {
|
|
156
|
+
try {
|
|
157
|
+
modules.push(await load(file));
|
|
158
|
+
} catch (error) {
|
|
159
|
+
return err(ioFailed("source", "discover", `failed to import ${file}: ${String(error)}`));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return ok(collectDefinitions(modules));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface LoadOptions {
|
|
166
|
+
readonly cwd: string;
|
|
167
|
+
readonly import?: ModuleImporter;
|
|
168
|
+
readonly list?: FileLister;
|
|
169
|
+
/** Directory scanned when the target is a registered name; default `<cwd>/.orca/loops`. */
|
|
170
|
+
readonly loopsDir?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Resolve a loop by module path OR registered name (spec distribution). A path imports that one
|
|
175
|
+
* module; a name discovers the `.orca/loops` directory and looks it up. Import-only: no firing.
|
|
176
|
+
*/
|
|
177
|
+
export async function loadDefinition(
|
|
178
|
+
target: string,
|
|
179
|
+
options: LoadOptions,
|
|
180
|
+
): Promise<Result<LoopDefinition, RuntimeError>> {
|
|
181
|
+
const load = options.import ?? defaultImporter;
|
|
182
|
+
const asPath = isAbsolute(target) ? target : resolve(options.cwd, target);
|
|
183
|
+
if (existsSync(asPath) && statSync(asPath).isFile()) {
|
|
184
|
+
let module: Record<string, unknown>;
|
|
185
|
+
try {
|
|
186
|
+
module = await load(asPath);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
return err(ioFailed("source", "load", `failed to import ${target}: ${String(error)}`));
|
|
189
|
+
}
|
|
190
|
+
const picked = pickDefinition(module, collectDefinitions([module]));
|
|
191
|
+
if (picked === undefined) {
|
|
192
|
+
return err(ioFailed("source", "load", `no loop definition exported from ${target}`));
|
|
193
|
+
}
|
|
194
|
+
return ok(picked);
|
|
195
|
+
}
|
|
196
|
+
const dir = options.loopsDir ?? join(options.cwd, ".orca", "loops");
|
|
197
|
+
const discovered = await discoverLoops({
|
|
198
|
+
dir,
|
|
199
|
+
...(options.import === undefined ? {} : { import: options.import }),
|
|
200
|
+
...(options.list === undefined ? {} : { list: options.list }),
|
|
201
|
+
});
|
|
202
|
+
if (discovered.isErr()) {
|
|
203
|
+
return err(discovered.error);
|
|
204
|
+
}
|
|
205
|
+
const found = discovered.value.get(target);
|
|
206
|
+
if (found === undefined) {
|
|
207
|
+
return err(ioFailed("source", "load", `no loop named "${target}" found in ${dir}`));
|
|
208
|
+
}
|
|
209
|
+
return ok(found);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A `default` export wins; otherwise a sole named definition; ambiguous (>1, no default) → none. */
|
|
213
|
+
function pickDefinition(
|
|
214
|
+
module: Record<string, unknown>,
|
|
215
|
+
defs: Map<string, LoopDefinition>,
|
|
216
|
+
): LoopDefinition | undefined {
|
|
217
|
+
const fallback = module.default;
|
|
218
|
+
if (isLoopDefinition(fallback)) {
|
|
219
|
+
return fallback;
|
|
220
|
+
}
|
|
221
|
+
if (defs.size === 1) {
|
|
222
|
+
return [...defs.values()].at(0);
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- Listing: project definitions to rows; reads metadata only, fires nothing. ---
|
|
228
|
+
|
|
229
|
+
export interface LoopListing {
|
|
230
|
+
readonly name: string;
|
|
231
|
+
readonly source: string;
|
|
232
|
+
readonly sink: string;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Project loop definitions to listing rows — reads `kind` metadata only, never `start`/`emit`/`run`. */
|
|
236
|
+
export function listLoops(definitions: Iterable<LoopDefinition>): LoopListing[] {
|
|
237
|
+
const rows: LoopListing[] = [];
|
|
238
|
+
for (const definition of definitions) {
|
|
239
|
+
rows.push({ name: definition.name, source: definition.source.kind, sink: definition.sink.kind });
|
|
240
|
+
}
|
|
241
|
+
return rows;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Render the loop listing as text for `orcats loops`. */
|
|
245
|
+
export function formatLoopListing(rows: readonly LoopListing[]): string {
|
|
246
|
+
if (rows.length === 0) {
|
|
247
|
+
return "No loops defined. Add an export-only module under .orca/loops that exports a defineLoop(...) result.";
|
|
248
|
+
}
|
|
249
|
+
return rows.map((row) => `${row.name}\tsource=${row.source}\tsink=${row.sink}`).join("\n");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// --- serve: a thin long-lived supervisor that spawns an ephemeral child per trigger firing (D8). ---
|
|
253
|
+
|
|
254
|
+
export interface ServeOptions {
|
|
255
|
+
/** Spawn an ephemeral child per firing; default re-invokes `orcats run <loop>` as a subprocess. */
|
|
256
|
+
readonly spawn?: ChildSpawner;
|
|
257
|
+
/** What the child runs: a loop name or path. Default: the definition name. */
|
|
258
|
+
readonly loopRef?: string;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Live supervisor handle: owns the trigger and the in-flight children, not the loop runs. */
|
|
262
|
+
export interface Supervisor {
|
|
263
|
+
/** In-flight child handles, one per firing not yet exited. */
|
|
264
|
+
children(): readonly ChildHandle[];
|
|
265
|
+
/** Stop the trigger and `SIGKILL` any in-flight children. */
|
|
266
|
+
stop(): Promise<Result<void, RuntimeError>>;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The thin `serve` supervisor (design D8): a long-lived process owning ONLY the trigger. Each
|
|
271
|
+
* firing spawns an ephemeral child that runs the loop and exits — the loop never runs in the
|
|
272
|
+
* supervisor, so one loop's crash/OOM cannot take down the supervisor or its siblings, and a
|
|
273
|
+
* runaway child is killable at the OS level. Cross-loop coordination is via the shared manifest
|
|
274
|
+
* store (L05), not shared process memory.
|
|
275
|
+
*/
|
|
276
|
+
export async function serve(
|
|
277
|
+
definition: LoopDefinition,
|
|
278
|
+
options: ServeOptions = {},
|
|
279
|
+
): Promise<Result<Supervisor, RuntimeError>> {
|
|
280
|
+
const spawnChild = options.spawn ?? spawnLoopChild;
|
|
281
|
+
const loopRef = options.loopRef ?? definition.name;
|
|
282
|
+
const children = new Set<ChildHandle>();
|
|
283
|
+
|
|
284
|
+
const started = await definition.source.start((event: unknown) => {
|
|
285
|
+
const child = spawnChild(createLoopChildSpec(loopRef, event));
|
|
286
|
+
children.add(child);
|
|
287
|
+
const forget = (): void => {
|
|
288
|
+
children.delete(child);
|
|
289
|
+
};
|
|
290
|
+
void child.exited.then(forget, forget);
|
|
291
|
+
});
|
|
292
|
+
if (started.isErr()) {
|
|
293
|
+
return err(started.error);
|
|
294
|
+
}
|
|
295
|
+
const subscription = started.value;
|
|
296
|
+
|
|
297
|
+
return ok({
|
|
298
|
+
children: () => [...children],
|
|
299
|
+
async stop() {
|
|
300
|
+
for (const child of children) {
|
|
301
|
+
child.kill("SIGKILL");
|
|
302
|
+
}
|
|
303
|
+
children.clear();
|
|
304
|
+
return subscription.stop();
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type Result } from "neverthrow";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { parseStructuredOutput, type RuntimeError } from "../../model/index.ts";
|
|
4
|
+
|
|
5
|
+
// The loop manifest is runtime state (design D4) — the "Ralph" pattern. It is the
|
|
6
|
+
// single spine for progress, the termination variant, and the monitor stream, all
|
|
7
|
+
// projected from the same pass-flags. It does NOT replace `.orca/plan-<hash>.md`,
|
|
8
|
+
// which remains the human plan artifact recovered by the plans-and-review APIs.
|
|
9
|
+
|
|
10
|
+
/** A single atomic subtask; `passes` is the progress flag flipped by the loop. */
|
|
11
|
+
export const ManifestTaskSchema = z.object({
|
|
12
|
+
id: z.string(),
|
|
13
|
+
passes: z.boolean()
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/** Typed loop manifest — the single progress/variant/monitor spine. */
|
|
17
|
+
export const TaskManifestSchema = z.object({
|
|
18
|
+
tasks: z.array(ManifestTaskSchema)
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type ManifestTask = z.infer<typeof ManifestTaskSchema>;
|
|
22
|
+
export type TaskManifest = z.infer<typeof TaskManifestSchema>;
|
|
23
|
+
|
|
24
|
+
/** Validate raw state against the manifest schema each cycle; a validation
|
|
25
|
+
* failure surfaces as `err(RuntimeError)` (`StructuredOutputValidationFailed`). */
|
|
26
|
+
export function parseManifest(raw: unknown): Result<TaskManifest, RuntimeError> {
|
|
27
|
+
return parseStructuredOutput(TaskManifestSchema, raw).map((parsed) => parsed.value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The single shared projection: count of tasks not yet passing. The termination
|
|
31
|
+
* variant, reported progress, and the monitor signal all read this one value, so
|
|
32
|
+
* they always reflect the same number. */
|
|
33
|
+
export function measure(manifest: TaskManifest): number {
|
|
34
|
+
return manifest.tasks.reduce((pending, task) => (task.passes ? pending : pending + 1), 0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ManifestProgress {
|
|
38
|
+
readonly total: number;
|
|
39
|
+
readonly passed: number;
|
|
40
|
+
readonly pending: number;
|
|
41
|
+
/** Fraction of tasks passing in `[0, 1]`; `1` for an empty manifest. */
|
|
42
|
+
readonly ratio: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Progress report derived from {@link measure} so it cannot drift from the variant. */
|
|
46
|
+
export function progress(manifest: TaskManifest): ManifestProgress {
|
|
47
|
+
const total = manifest.tasks.length;
|
|
48
|
+
const pending = measure(manifest);
|
|
49
|
+
const passed = total - pending;
|
|
50
|
+
return { total, passed, pending, ratio: total === 0 ? 1 : passed / total };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Termination predicate: the manifest is complete when nothing is pending. */
|
|
54
|
+
export function isComplete(manifest: TaskManifest): boolean {
|
|
55
|
+
return measure(manifest) === 0;
|
|
56
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Result } from "neverthrow";
|
|
2
|
+
import type { RuntimeError } from "../../model/index.ts";
|
|
3
|
+
import type { TaskManifest } from "./manifest.ts";
|
|
4
|
+
|
|
5
|
+
// The StateStore port is the single seam for loop state (design D4). Loop code
|
|
6
|
+
// targets this interface; swapping the adapter (snapshot -> sqlite -> ...) never
|
|
7
|
+
// changes the loop definition. branch/merge/history are first-class operations:
|
|
8
|
+
// fan-out = branch, fan-in = merge, monitoring = history. Store-backed fan-out
|
|
9
|
+
// additionally requires BranchWritableStateStore for non-history branch saves.
|
|
10
|
+
|
|
11
|
+
/** Content hash identifying a per-cycle state snapshot. */
|
|
12
|
+
export type StateHash = string;
|
|
13
|
+
|
|
14
|
+
/** Service-free-by-default adapters (design D4). `dbos`/`dolt` stay deferred but
|
|
15
|
+
* the port shape keeps them expressible. */
|
|
16
|
+
export type StateAdapterId = "snapshot" | "sqlite";
|
|
17
|
+
|
|
18
|
+
/** Fan-in merge: the ONLY place branch state combines (event-sourced/reduce,
|
|
19
|
+
* never shared-mutable). */
|
|
20
|
+
export type StateReducer<S> = (states: readonly S[]) => S;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* StateStore port — `load / checkpoint / branch / merge / history`, all Result-typed
|
|
24
|
+
* over `RuntimeError`. Default state is the {@link TaskManifest}.
|
|
25
|
+
*/
|
|
26
|
+
export interface StateStore<S = TaskManifest> {
|
|
27
|
+
/** Read a snapshot by hash, or the most recent checkpoint when omitted. */
|
|
28
|
+
load(hash?: StateHash): Promise<Result<S, RuntimeError>>;
|
|
29
|
+
/** Validate and persist a cycle's state, returning its content hash. */
|
|
30
|
+
checkpoint(state: S): Promise<Result<StateHash, RuntimeError>>;
|
|
31
|
+
/** Fan-out: copy a snapshot into an isolated branch handle. */
|
|
32
|
+
branch(from: StateHash): Promise<Result<StateHash, RuntimeError>>;
|
|
33
|
+
/** Fan-in: combine branch snapshots through the reducer (the only merge point). */
|
|
34
|
+
merge(branches: readonly StateHash[], reducer: StateReducer<S>): Promise<Result<S, RuntimeError>>;
|
|
35
|
+
/** Monitoring: the ordered hashes of prior cycle snapshots. */
|
|
36
|
+
history(): Promise<Result<readonly StateHash[], RuntimeError>>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Store capability required only by store-backed fan-out branch result persistence. */
|
|
40
|
+
export interface BranchWritableStateStore<S = TaskManifest> extends StateStore<S> {
|
|
41
|
+
/** Persist isolated branch state without appending to the cycle history. */
|
|
42
|
+
saveBranch(branch: StateHash, state: S): Promise<Result<StateHash, RuntimeError>>;
|
|
43
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { err, ok, type Result } from "neverthrow";
|
|
4
|
+
import type { RuntimeError } from "../../model/index.ts";
|
|
5
|
+
import { createFsTool, type FsTool } from "../../tools/index.ts";
|
|
6
|
+
import { parseManifest, type TaskManifest } from "./manifest.ts";
|
|
7
|
+
import type { BranchWritableStateStore, StateHash, StateReducer } from "./port.ts";
|
|
8
|
+
|
|
9
|
+
// `snapshot` is the zero-config default adapter (design D4): the whole manifest is
|
|
10
|
+
// written as JSON to `.orca/state-<hash>.json` per cycle — human-readable,
|
|
11
|
+
// git-diffable, zero deps. `branch` = copy-on-fanout, `saveBranch` = non-history
|
|
12
|
+
// branch result write, `merge` = reducer (the only merge point), `history` = the
|
|
13
|
+
// in-process cycle stream. The snapshot adapter
|
|
14
|
+
// trades durability for simplicity: an interrupt loses the current cycle, so the
|
|
15
|
+
// monitor history lives in memory; finer per-step resume is the `sqlite` adapter.
|
|
16
|
+
|
|
17
|
+
export interface SnapshotStoreOptions {
|
|
18
|
+
/** Repo/working root; snapshots land under `<root>/.orca/`. */
|
|
19
|
+
readonly root: string;
|
|
20
|
+
/** Injectable filesystem for tests; defaults to the real adapter. */
|
|
21
|
+
readonly fsTool?: FsTool;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Content hash of a manifest — canonical JSON so equal manifests share a file. */
|
|
25
|
+
export function stateHash(manifest: TaskManifest): StateHash {
|
|
26
|
+
const canonical = JSON.stringify({
|
|
27
|
+
tasks: manifest.tasks.map(({ id, passes }) => ({ id, passes }))
|
|
28
|
+
});
|
|
29
|
+
return createHash("sha256").update(canonical).digest("hex").slice(0, 12);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Per-cycle snapshot path: `<root>/.orca/state-<hash>.json`. */
|
|
33
|
+
export function statePath(root: string, hash: StateHash): string {
|
|
34
|
+
return join(root, ".orca", `state-${hash}.json`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createSnapshotStore(options: SnapshotStoreOptions): BranchWritableStateStore {
|
|
38
|
+
const { root } = options;
|
|
39
|
+
const fsTool = options.fsTool ?? createFsTool();
|
|
40
|
+
const cycles: StateHash[] = [];
|
|
41
|
+
let branchNonce = 0;
|
|
42
|
+
|
|
43
|
+
async function read(hash: StateHash): Promise<Result<TaskManifest, RuntimeError>> {
|
|
44
|
+
const text = await fsTool.readText(statePath(root, hash));
|
|
45
|
+
if (text.isErr()) {
|
|
46
|
+
return err(text.error);
|
|
47
|
+
}
|
|
48
|
+
let raw: unknown;
|
|
49
|
+
try {
|
|
50
|
+
raw = JSON.parse(text.value);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return err({ _tag: "FileSystemError", path: statePath(root, hash), message: String(error) });
|
|
53
|
+
}
|
|
54
|
+
return parseManifest(raw);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function write(manifest: TaskManifest): Promise<Result<StateHash, RuntimeError>> {
|
|
58
|
+
const hash = stateHash(manifest);
|
|
59
|
+
const written = await fsTool.writeText(statePath(root, hash), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
60
|
+
if (written.isErr()) {
|
|
61
|
+
return err(written.error);
|
|
62
|
+
}
|
|
63
|
+
return ok(hash);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
async load(hash) {
|
|
68
|
+
const target = hash ?? cycles.at(-1);
|
|
69
|
+
if (target === undefined) {
|
|
70
|
+
return err({ _tag: "FileSystemError", path: join(root, ".orca"), message: "no checkpoint to load" });
|
|
71
|
+
}
|
|
72
|
+
return read(target);
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
async checkpoint(state) {
|
|
76
|
+
const validated = parseManifest(state);
|
|
77
|
+
if (validated.isErr()) {
|
|
78
|
+
return err(validated.error);
|
|
79
|
+
}
|
|
80
|
+
const written = await write(validated.value);
|
|
81
|
+
if (written.isErr()) {
|
|
82
|
+
return err(written.error);
|
|
83
|
+
}
|
|
84
|
+
cycles.push(written.value);
|
|
85
|
+
return ok(written.value);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async branch(from) {
|
|
89
|
+
const source = await read(from);
|
|
90
|
+
if (source.isErr()) {
|
|
91
|
+
return err(source.error);
|
|
92
|
+
}
|
|
93
|
+
const hash = createHash("sha256").update(`${from}:${String(branchNonce++)}`).digest("hex").slice(0, 12);
|
|
94
|
+
const written = await fsTool.writeText(statePath(root, hash), `${JSON.stringify(source.value, null, 2)}\n`);
|
|
95
|
+
if (written.isErr()) {
|
|
96
|
+
return err(written.error);
|
|
97
|
+
}
|
|
98
|
+
return ok(hash);
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async saveBranch(branch, state) {
|
|
102
|
+
const source = await read(branch);
|
|
103
|
+
if (source.isErr()) {
|
|
104
|
+
return err(source.error);
|
|
105
|
+
}
|
|
106
|
+
const validated = parseManifest(state);
|
|
107
|
+
if (validated.isErr()) {
|
|
108
|
+
return err(validated.error);
|
|
109
|
+
}
|
|
110
|
+
return write(validated.value);
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async merge(branches, reducer: StateReducer<TaskManifest>) {
|
|
114
|
+
const states: TaskManifest[] = [];
|
|
115
|
+
for (const hash of branches) {
|
|
116
|
+
const loaded = await read(hash);
|
|
117
|
+
if (loaded.isErr()) {
|
|
118
|
+
return err(loaded.error);
|
|
119
|
+
}
|
|
120
|
+
states.push(loaded.value);
|
|
121
|
+
}
|
|
122
|
+
return parseManifest(reducer(states));
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
history() {
|
|
126
|
+
return Promise.resolve(ok([...cycles]));
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|