@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,170 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { err, ok, type Result } from "neverthrow";
|
|
5
|
+
|
|
6
|
+
import { ioFailed, type RuntimeError } from "../../model/index.ts";
|
|
7
|
+
import { createGitHubTool, type GitHubTool } from "../../tools/index.ts";
|
|
8
|
+
|
|
9
|
+
// Sink = the loop-level output boundary (spec loop-io, design D8). A Sink emits a typed output and
|
|
10
|
+
// returns a `Result`; an emit failure surfaces as `err(RuntimeError)`, never a thrown exception. A
|
|
11
|
+
// custom output implements only `Sink` — no engine change.
|
|
12
|
+
|
|
13
|
+
/** Bundled sink kinds. A custom output reuses the closest kind or its own structural object. */
|
|
14
|
+
export type SinkKind = "pr" | "file" | "slack" | "queue" | "stdout" | "linear-issue" | "linear-agent";
|
|
15
|
+
|
|
16
|
+
/** A loop output — the only loop-level emit boundary; Result-typed. */
|
|
17
|
+
export interface Sink<A = unknown> {
|
|
18
|
+
readonly kind: SinkKind;
|
|
19
|
+
emit(output: A): Promise<Result<void, RuntimeError>>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Default rendering: strings pass through; everything else is pretty JSON. */
|
|
23
|
+
function renderOutput<A>(output: A, format?: (output: A) => string): string {
|
|
24
|
+
if (format !== undefined) {
|
|
25
|
+
return format(output);
|
|
26
|
+
}
|
|
27
|
+
return typeof output === "string" ? output : JSON.stringify(output, null, 2);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// --- stdout: write the rendered output to a stream (default process.stdout). ---
|
|
31
|
+
|
|
32
|
+
export interface StdoutOptions<A> {
|
|
33
|
+
readonly format?: (output: A) => string;
|
|
34
|
+
/** Write target; default: process.stdout. Injectable for tests. */
|
|
35
|
+
readonly write?: (text: string) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function stdout<A = unknown>(options: StdoutOptions<A> = {}): Sink<A> {
|
|
39
|
+
const write =
|
|
40
|
+
options.write ??
|
|
41
|
+
((text: string) => {
|
|
42
|
+
process.stdout.write(text);
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
kind: "stdout",
|
|
46
|
+
emit(output) {
|
|
47
|
+
try {
|
|
48
|
+
const text = renderOutput(output, options.format);
|
|
49
|
+
write(text.endsWith("\n") ? text : `${text}\n`);
|
|
50
|
+
return Promise.resolve(ok(undefined));
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return Promise.resolve(err(ioFailed("sink", "stdout", String(error))));
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- file: write the rendered output to a path (FileSystemError on failure, mirroring FsTool). ---
|
|
59
|
+
|
|
60
|
+
export interface FileSinkOptions<A> {
|
|
61
|
+
readonly path: string;
|
|
62
|
+
readonly format?: (output: A) => string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function file<A = unknown>(options: FileSinkOptions<A>): Sink<A> {
|
|
66
|
+
return {
|
|
67
|
+
kind: "file",
|
|
68
|
+
async emit(output) {
|
|
69
|
+
try {
|
|
70
|
+
await mkdir(dirname(options.path), { recursive: true });
|
|
71
|
+
await writeFile(options.path, renderOutput(output, options.format));
|
|
72
|
+
return ok(undefined);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return err({ _tag: "FileSystemError", path: options.path, message: String(error) });
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- pr: write the rendered output to a body file, then open a PR via the gh CLI tool. ---
|
|
81
|
+
|
|
82
|
+
export interface PrSinkOptions<A> {
|
|
83
|
+
readonly title: string;
|
|
84
|
+
readonly base?: string;
|
|
85
|
+
/** Body file written before `gh pr create --body-file`; default: a tmpdir file. */
|
|
86
|
+
readonly bodyFile?: string;
|
|
87
|
+
readonly format?: (output: A) => string;
|
|
88
|
+
/** GitHub client; default: the repo gh CLI tool. Injectable for tests. */
|
|
89
|
+
readonly gh?: GitHubTool;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function pr<A = unknown>(options: PrSinkOptions<A>): Sink<A> {
|
|
93
|
+
return {
|
|
94
|
+
kind: "pr",
|
|
95
|
+
async emit(output) {
|
|
96
|
+
const gh = options.gh ?? createGitHubTool(process.cwd());
|
|
97
|
+
const bodyFile = options.bodyFile ?? join(tmpdir(), `orca-pr-body-${String(Date.now())}.md`);
|
|
98
|
+
try {
|
|
99
|
+
await mkdir(dirname(bodyFile), { recursive: true });
|
|
100
|
+
await writeFile(bodyFile, renderOutput(output, options.format));
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return err({ _tag: "FileSystemError", path: bodyFile, message: String(error) });
|
|
103
|
+
}
|
|
104
|
+
const created = await gh.createPullRequest({
|
|
105
|
+
title: options.title,
|
|
106
|
+
bodyFile,
|
|
107
|
+
...(options.base === undefined ? {} : { base: options.base }),
|
|
108
|
+
});
|
|
109
|
+
return created.map(() => undefined);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- slack: POST the rendered output to a Slack incoming webhook; the post fn is injectable. ---
|
|
115
|
+
|
|
116
|
+
/** POSTs `body` to `url`; resolves to `ok` on a 2xx, `err(RuntimeError)` otherwise. */
|
|
117
|
+
export type SlackPost = (url: string, body: string) => Promise<Result<void, RuntimeError>>;
|
|
118
|
+
|
|
119
|
+
const defaultSlackPost: SlackPost = async (url, body) => {
|
|
120
|
+
try {
|
|
121
|
+
const response = await fetch(url, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "content-type": "application/json" },
|
|
124
|
+
body: JSON.stringify({ text: body }),
|
|
125
|
+
});
|
|
126
|
+
if (!response.ok) {
|
|
127
|
+
return err(ioFailed("sink", "slack", `slack webhook returned HTTP ${String(response.status)}`));
|
|
128
|
+
}
|
|
129
|
+
return ok(undefined);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return err(ioFailed("sink", "slack", String(error)));
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export interface SlackOptions<A> {
|
|
136
|
+
readonly webhookUrl: string;
|
|
137
|
+
readonly format?: (output: A) => string;
|
|
138
|
+
readonly post?: SlackPost;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function slack<A = unknown>(options: SlackOptions<A>): Sink<A> {
|
|
142
|
+
const post = options.post ?? defaultSlackPost;
|
|
143
|
+
return {
|
|
144
|
+
kind: "slack",
|
|
145
|
+
emit(output) {
|
|
146
|
+
return post(options.webhookUrl, renderOutput(output, options.format));
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- queue: broker-backed output. There is no in-repo broker, so the producer is injected (the ---
|
|
152
|
+
// --- bundled Sink is the seam adapter). Exported as `queueSink` because the Source also ships a ---
|
|
153
|
+
// --- `queue`; the two factories cannot share a bare name across the io surface. ---
|
|
154
|
+
|
|
155
|
+
export interface QueueProducer<A> {
|
|
156
|
+
push(message: A): Promise<Result<void, RuntimeError>>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface QueueSinkOptions<A> {
|
|
160
|
+
readonly producer: QueueProducer<A>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function queueSink<A = unknown>(options: QueueSinkOptions<A>): Sink<A> {
|
|
164
|
+
return {
|
|
165
|
+
kind: "queue",
|
|
166
|
+
emit(output) {
|
|
167
|
+
return options.producer.push(output);
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { watch as fsWatch } from "node:fs";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import type { IncomingMessage, Server, ServerResponse } from "node:http";
|
|
4
|
+
import { err, ok, type Result } from "neverthrow";
|
|
5
|
+
|
|
6
|
+
import { ioFailed, unsupportedFeature, type RuntimeError } from "../../model/index.ts";
|
|
7
|
+
|
|
8
|
+
// Source = the loop-level trigger boundary (spec loop-io, design D8). A Source subscribes a
|
|
9
|
+
// handler to trigger events and returns a stop handle; the `serve` supervisor (L11) owns the
|
|
10
|
+
// long-lived process that drives Sources. A custom trigger implements only `Source` — no engine
|
|
11
|
+
// change. All failures surface as `err(RuntimeError)`; a Source never throws across `start`/`stop`.
|
|
12
|
+
|
|
13
|
+
/** Bundled source kinds. A custom trigger reuses the closest kind or its own structural object. */
|
|
14
|
+
export type SourceKind = "manual" | "cron" | "watch" | "webhook" | "queue" | "linear-issue" | "linear-agent";
|
|
15
|
+
|
|
16
|
+
/** Handle returned by a started Source so the supervisor can stop the trigger. */
|
|
17
|
+
export interface SourceSubscription {
|
|
18
|
+
stop(): Promise<Result<void, RuntimeError>>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A loop trigger — the only loop-level entry boundary. */
|
|
22
|
+
export interface Source<E = unknown> {
|
|
23
|
+
readonly kind: SourceKind;
|
|
24
|
+
start(handler: (event: E) => void): Promise<Result<SourceSubscription, RuntimeError>>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A started Source returns this so teardown is a plain `Result`-typed call. */
|
|
28
|
+
function subscription(stop: () => void): SourceSubscription {
|
|
29
|
+
return {
|
|
30
|
+
stop() {
|
|
31
|
+
stop();
|
|
32
|
+
return Promise.resolve(ok(undefined));
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// --- manual: in-memory trigger; `fire(event)` pushes to the started handler. No real IO. ---
|
|
38
|
+
|
|
39
|
+
export interface ManualSource<E = void> extends Source<E> {
|
|
40
|
+
readonly kind: "manual";
|
|
41
|
+
/** Push a trigger event to the started handler. No-op until started / after stop. */
|
|
42
|
+
fire(event: E): void;
|
|
43
|
+
/** Whether a handler is currently subscribed. */
|
|
44
|
+
isStarted(): boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function manual<E = void>(): ManualSource<E> {
|
|
48
|
+
let handler: ((event: E) => void) | undefined;
|
|
49
|
+
return {
|
|
50
|
+
kind: "manual",
|
|
51
|
+
isStarted: () => handler !== undefined,
|
|
52
|
+
fire(event) {
|
|
53
|
+
handler?.(event);
|
|
54
|
+
},
|
|
55
|
+
start(h) {
|
|
56
|
+
handler = h;
|
|
57
|
+
return Promise.resolve(ok(subscription(() => {
|
|
58
|
+
handler = undefined;
|
|
59
|
+
})));
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- cron: cadence-driven trigger. The serve supervisor (design D8) owns cron-expression ---
|
|
65
|
+
// --- parsing + scheduling and supplies the scheduler; tests supply a fake. Unbound = err. ---
|
|
66
|
+
|
|
67
|
+
/** Schedules `fire` on the loop's cadence and returns a cancel handle. */
|
|
68
|
+
export type CronScheduler = (fire: () => void) => () => void;
|
|
69
|
+
|
|
70
|
+
export function cron(expression: string, scheduler?: CronScheduler): Source<void> {
|
|
71
|
+
return {
|
|
72
|
+
kind: "cron",
|
|
73
|
+
start(handler) {
|
|
74
|
+
if (scheduler === undefined) {
|
|
75
|
+
return Promise.resolve(
|
|
76
|
+
err(
|
|
77
|
+
unsupportedFeature(
|
|
78
|
+
"cron",
|
|
79
|
+
`no scheduler bound for "${expression}"; the serve supervisor (L11) owns cron scheduling`,
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const cancel = scheduler(() => {
|
|
85
|
+
handler();
|
|
86
|
+
});
|
|
87
|
+
return Promise.resolve(ok(subscription(cancel)));
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- watch: filesystem trigger over node:fs watch; the watcher factory is injectable so tests ---
|
|
93
|
+
// --- exercise the adapter without real fs IO. ---
|
|
94
|
+
|
|
95
|
+
export interface WatchEvent {
|
|
96
|
+
readonly eventType: string;
|
|
97
|
+
readonly filename: string | null;
|
|
98
|
+
/** The watched path that produced the event. */
|
|
99
|
+
readonly path: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Opens a watcher on `path`, invoking `onEvent` per change; returns a close handle. */
|
|
103
|
+
export type WatcherFactory = (
|
|
104
|
+
path: string,
|
|
105
|
+
onEvent: (eventType: string, filename: string | null) => void,
|
|
106
|
+
) => { close(): void };
|
|
107
|
+
|
|
108
|
+
const defaultWatcherFactory: WatcherFactory = (path, onEvent) => {
|
|
109
|
+
const watcher = fsWatch(path, (eventType, filename) => {
|
|
110
|
+
onEvent(eventType, filename);
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
close: () => {
|
|
114
|
+
watcher.close();
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export interface WatchOptions {
|
|
120
|
+
readonly paths: readonly string[];
|
|
121
|
+
readonly watcherFactory?: WatcherFactory;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function watch(options: WatchOptions): Source<WatchEvent> {
|
|
125
|
+
const factory = options.watcherFactory ?? defaultWatcherFactory;
|
|
126
|
+
return {
|
|
127
|
+
kind: "watch",
|
|
128
|
+
start(handler) {
|
|
129
|
+
const watchers: { close(): void }[] = [];
|
|
130
|
+
try {
|
|
131
|
+
for (const path of options.paths) {
|
|
132
|
+
watchers.push(
|
|
133
|
+
factory(path, (eventType, filename) => {
|
|
134
|
+
handler({ eventType, filename, path });
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
} catch (error) {
|
|
139
|
+
for (const watcher of watchers) {
|
|
140
|
+
watcher.close();
|
|
141
|
+
}
|
|
142
|
+
return Promise.resolve(err(ioFailed("source", "watch", String(error))));
|
|
143
|
+
}
|
|
144
|
+
return Promise.resolve(
|
|
145
|
+
ok(
|
|
146
|
+
subscription(() => {
|
|
147
|
+
for (const watcher of watchers) {
|
|
148
|
+
watcher.close();
|
|
149
|
+
}
|
|
150
|
+
}),
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- webhook: inbound-HTTP trigger over a node:http server; the listener factory is injectable ---
|
|
158
|
+
// --- so tests exercise the adapter without binding a real socket. ---
|
|
159
|
+
|
|
160
|
+
export interface WebhookEvent {
|
|
161
|
+
readonly method: string;
|
|
162
|
+
readonly url: string;
|
|
163
|
+
readonly body: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Starts a listener that invokes `onRequest` per inbound request and returns a close handle. */
|
|
167
|
+
export type ListenerFactory = (
|
|
168
|
+
onRequest: (event: WebhookEvent) => void,
|
|
169
|
+
) => Promise<Result<{ close(): Promise<void> }, RuntimeError>>;
|
|
170
|
+
|
|
171
|
+
export interface WebhookOptions {
|
|
172
|
+
readonly port: number;
|
|
173
|
+
/** Only requests to this path fire the handler; default: any path. */
|
|
174
|
+
readonly path?: string;
|
|
175
|
+
readonly listenerFactory?: ListenerFactory;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function defaultListenerFactory(port: number, path: string | undefined): ListenerFactory {
|
|
179
|
+
return (onRequest) =>
|
|
180
|
+
new Promise((resolve) => {
|
|
181
|
+
const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
182
|
+
const chunks: Buffer[] = [];
|
|
183
|
+
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
184
|
+
req.on("end", () => {
|
|
185
|
+
const url = req.url ?? "/";
|
|
186
|
+
if (path === undefined || url === path) {
|
|
187
|
+
onRequest({ method: req.method ?? "GET", url, body: Buffer.concat(chunks).toString("utf8") });
|
|
188
|
+
}
|
|
189
|
+
res.statusCode = 200;
|
|
190
|
+
res.end();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
server.on("error", (error) => {
|
|
194
|
+
resolve(err(ioFailed("source", "webhook", String(error))));
|
|
195
|
+
});
|
|
196
|
+
server.listen(port, () => {
|
|
197
|
+
resolve(
|
|
198
|
+
ok({
|
|
199
|
+
close: () =>
|
|
200
|
+
new Promise<void>((done) => {
|
|
201
|
+
server.close(() => {
|
|
202
|
+
done();
|
|
203
|
+
});
|
|
204
|
+
}),
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function webhook(options: WebhookOptions): Source<WebhookEvent> {
|
|
212
|
+
const factory = options.listenerFactory ?? defaultListenerFactory(options.port, options.path);
|
|
213
|
+
return {
|
|
214
|
+
kind: "webhook",
|
|
215
|
+
async start(handler) {
|
|
216
|
+
const listener = await factory(handler);
|
|
217
|
+
if (listener.isErr()) {
|
|
218
|
+
return err(listener.error);
|
|
219
|
+
}
|
|
220
|
+
const handle = listener.value;
|
|
221
|
+
return ok({
|
|
222
|
+
async stop() {
|
|
223
|
+
await handle.close();
|
|
224
|
+
return ok(undefined);
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- queue: broker-backed trigger. There is no in-repo broker, so the consumer is injected ---
|
|
232
|
+
// --- (the bundled Source is the seam adapter). Exported as `queueSource` because the Sink also ---
|
|
233
|
+
// --- ships a `queue`; the two factories cannot share a bare name across the io surface. ---
|
|
234
|
+
|
|
235
|
+
export interface QueueConsumer<E> {
|
|
236
|
+
/** Subscribe to messages, delivering each; resolves to an unsubscribe handle. */
|
|
237
|
+
subscribe(deliver: (message: E) => void): Promise<Result<() => void, RuntimeError>>;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface QueueSourceOptions<E> {
|
|
241
|
+
readonly consumer: QueueConsumer<E>;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function queueSource<E = unknown>(options: QueueSourceOptions<E>): Source<E> {
|
|
245
|
+
return {
|
|
246
|
+
kind: "queue",
|
|
247
|
+
async start(handler) {
|
|
248
|
+
const subscribed = await options.consumer.subscribe(handler);
|
|
249
|
+
if (subscribed.isErr()) {
|
|
250
|
+
return err(subscribed.error);
|
|
251
|
+
}
|
|
252
|
+
return ok(subscription(subscribed.value));
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|