@opsee/cli 0.11.9
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/README.md +1962 -0
- package/bin/opsee.js +28 -0
- package/package.json +40 -0
- package/skills/README.md +3 -0
- package/skills/to-issues/SKILL.md +92 -0
- package/skills/to-issues/agents/openai.yaml +5 -0
- package/skills/to-spec/SKILL.md +79 -0
- package/skills/to-spec/agents/openai.yaml +5 -0
- package/skills/wayfinder/SKILL.md +138 -0
- package/skills/wayfinder/agents/openai.yaml +5 -0
- package/src/args.ts +676 -0
- package/src/cli.ts +341 -0
- package/src/commands/account.ts +121 -0
- package/src/commands/deps.ts +11 -0
- package/src/commands/foreman-control.ts +242 -0
- package/src/commands/foreman-debug.ts +131 -0
- package/src/commands/foreman-plan.ts +213 -0
- package/src/commands/foreman-service.ts +186 -0
- package/src/commands/foreman-up.ts +165 -0
- package/src/commands/foreman-views.ts +398 -0
- package/src/commands/foreman.ts +465 -0
- package/src/commands/init.ts +176 -0
- package/src/commands/initiative.ts +192 -0
- package/src/commands/login.ts +24 -0
- package/src/commands/whoami.ts +15 -0
- package/src/foreman/account-store.ts +96 -0
- package/src/foreman/account.ts +474 -0
- package/src/foreman/claude-worker-adapter.ts +412 -0
- package/src/foreman/codex-worker-adapter.ts +472 -0
- package/src/foreman/completion-report.ts +153 -0
- package/src/foreman/core/context.ts +169 -0
- package/src/foreman/core/defects.ts +280 -0
- package/src/foreman/core/exec.ts +20 -0
- package/src/foreman/core/gates.ts +493 -0
- package/src/foreman/core/handoff.ts +163 -0
- package/src/foreman/core/install.ts +109 -0
- package/src/foreman/core/learnings.ts +368 -0
- package/src/foreman/core/outbox-tracker.ts +192 -0
- package/src/foreman/core/pin.ts +226 -0
- package/src/foreman/core/plan-context.ts +238 -0
- package/src/foreman/core/process-table.ts +535 -0
- package/src/foreman/core/reconcile.ts +227 -0
- package/src/foreman/core/report.ts +60 -0
- package/src/foreman/core/run.ts +2836 -0
- package/src/foreman/core/scheduler.ts +244 -0
- package/src/foreman/core/summary.ts +166 -0
- package/src/foreman/core/text.ts +97 -0
- package/src/foreman/core/transcripts.ts +38 -0
- package/src/foreman/core/triage.ts +138 -0
- package/src/foreman/core/verifier.ts +800 -0
- package/src/foreman/core/views.ts +940 -0
- package/src/foreman/core/work-contract.ts +152 -0
- package/src/foreman/core/workspace.ts +335 -0
- package/src/foreman/fake-handoff.ts +33 -0
- package/src/foreman/fake-learnings.ts +26 -0
- package/src/foreman/fake-remote-api.ts +70 -0
- package/src/foreman/fake-tracker-adapter.ts +355 -0
- package/src/foreman/fake-worker-adapter.ts +221 -0
- package/src/foreman/host.ts +75 -0
- package/src/foreman/local-dir.ts +28 -0
- package/src/foreman/opsee-tracker-adapter.ts +612 -0
- package/src/foreman/process-group.ts +160 -0
- package/src/foreman/remote-api.ts +283 -0
- package/src/foreman/run-recipe.ts +274 -0
- package/src/foreman/service-unit.ts +257 -0
- package/src/foreman/tracker-adapter.ts +298 -0
- package/src/foreman/triage-draft.ts +40 -0
- package/src/foreman/vendor.ts +23 -0
- package/src/foreman/verdict.ts +120 -0
- package/src/foreman/worker-adapter.ts +177 -0
- package/src/foreman/worker-process.ts +488 -0
- package/src/identity.ts +49 -0
- package/src/index.ts +3 -0
- package/src/init/managed.ts +84 -0
- package/src/init/mcp-config.ts +77 -0
- package/src/init/paths.ts +16 -0
- package/src/init/pointer-block.ts +45 -0
- package/src/init/project.ts +22 -0
- package/src/init/prompt.ts +45 -0
- package/src/init/run-recipe-config.ts +133 -0
- package/src/init/skills.ts +38 -0
- package/src/init/text.ts +22 -0
- package/src/init/tracker-doc.ts +106 -0
- package/src/opsee-config.ts +116 -0
- package/templates/issue-tracker.md +162 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency install as a Foreman setup step (story 20): run once per new Workspace, before the
|
|
3
|
+
* Worker, so Workers do not each spend turns discovering how to install. The command is the
|
|
4
|
+
* analyzer's `commands.install` from `.opsee/config` when the repo has one, else inferred from the
|
|
5
|
+
* lockfile at the Workspace root. A non-zero exit does not fail the dispatch; the loop logs it and
|
|
6
|
+
* records it in the attempt event, since the Worker may still be able to work (or to fix it).
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { commandsOf } from "../../init/run-recipe-config.js";
|
|
12
|
+
import { readOpseeConfigFiles } from "../../opsee-config.js";
|
|
13
|
+
|
|
14
|
+
export interface InstallCommand {
|
|
15
|
+
/** What decided it: `commands.install` or the lockfile's name. */
|
|
16
|
+
source: string;
|
|
17
|
+
argv: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Lockfile to install command, most specific first; the first match wins. */
|
|
21
|
+
const LOCKFILES: [string, string[]][] = [
|
|
22
|
+
["bun.lock", ["bun", "install", "--frozen-lockfile"]],
|
|
23
|
+
["bun.lockb", ["bun", "install", "--frozen-lockfile"]],
|
|
24
|
+
["pnpm-lock.yaml", ["pnpm", "install", "--frozen-lockfile"]],
|
|
25
|
+
["yarn.lock", ["yarn", "install", "--frozen-lockfile"]],
|
|
26
|
+
["package-lock.json", ["npm", "ci"]],
|
|
27
|
+
["go.mod", ["go", "mod", "download"]],
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export function inferInstallCommand(dir: string): InstallCommand | undefined {
|
|
31
|
+
const recipe = commandsOf(readOpseeConfigFiles(dir)).install;
|
|
32
|
+
if (recipe && recipe.trim() !== "") return { source: "commands.install", argv: ["sh", "-c", recipe] };
|
|
33
|
+
for (const [lockfile, argv] of LOCKFILES) {
|
|
34
|
+
if (existsSync(join(dir, lockfile))) return { source: lockfile, argv };
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface InstallResult {
|
|
40
|
+
command: string;
|
|
41
|
+
/** Null when the process was killed (a signal, the timeout) or could not be started. */
|
|
42
|
+
exitCode: number | null;
|
|
43
|
+
durationMs: number;
|
|
44
|
+
/** The last lines of combined output, for the attempt event. */
|
|
45
|
+
outputTail: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const OUTPUT_TAIL_LINES = 40;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How much of an output tail reaches an attempt event, in bytes. Forty lines is a shape, not a
|
|
52
|
+
* size: forty lines of a vitest diff, a webpack build or a stack trace is tens of thousands of
|
|
53
|
+
* characters on its own, and `RunAttemptEvent.summary` takes 20000 (proto/api/v1/initiative.proto).
|
|
54
|
+
* A tail bounded only by lines is how a summary grows past that limit, and a summary past the limit
|
|
55
|
+
* is an `InvalidArgument` the outbox refuses to queue — the attempt event is then lost outright,
|
|
56
|
+
* which under ADR-0009 is the loss of the commit itself. So the tail is bounded by both.
|
|
57
|
+
*/
|
|
58
|
+
export const OUTPUT_TAIL_BYTES = 4 * 1024;
|
|
59
|
+
|
|
60
|
+
/** The last `maxBytes` of `text` in UTF-8, cut on a character boundary. */
|
|
61
|
+
export function byteTail(text: string, maxBytes: number): string {
|
|
62
|
+
const buffer = Buffer.from(text, "utf8");
|
|
63
|
+
if (buffer.length <= maxBytes) return text;
|
|
64
|
+
let start = buffer.length - maxBytes;
|
|
65
|
+
// A continuation byte (10xxxxxx) is the middle of a character: move past it.
|
|
66
|
+
while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) start++;
|
|
67
|
+
return buffer.subarray(start).toString("utf8");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The last lines of `text`, and at most `maxBytes` of them: the failure is at the bottom, so what
|
|
71
|
+
* is dropped is dropped from the top, and the cut says so the way every other cut here does. */
|
|
72
|
+
export function tail(text: string, lines = OUTPUT_TAIL_LINES, maxBytes = OUTPUT_TAIL_BYTES): string {
|
|
73
|
+
const all = text.trimEnd().split("\n");
|
|
74
|
+
const kept = all.slice(Math.max(0, all.length - lines)).join("\n").trim();
|
|
75
|
+
const cut = byteTail(kept, maxBytes);
|
|
76
|
+
return cut === kept ? kept : `... (earlier output not shown)\n${cut}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Runs `command` in `dir` and reports how it went; never throws for a failing install. */
|
|
80
|
+
export function runInstall(command: InstallCommand, dir: string, options: { timeoutMs?: number } = {}): Promise<InstallResult> {
|
|
81
|
+
const started = Date.now();
|
|
82
|
+
const rendered = command.source === "commands.install" ? command.argv[2] : command.argv.join(" ");
|
|
83
|
+
return new Promise((resolvePromise) => {
|
|
84
|
+
let output = "";
|
|
85
|
+
let child: ReturnType<typeof spawn>;
|
|
86
|
+
try {
|
|
87
|
+
child = spawn(command.argv[0], command.argv.slice(1), { cwd: dir, stdio: ["ignore", "pipe", "pipe"], env: process.env });
|
|
88
|
+
} catch (error) {
|
|
89
|
+
resolvePromise({ command: rendered, exitCode: null, durationMs: Date.now() - started, outputTail: (error as Error).message });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const timer = options.timeoutMs
|
|
93
|
+
? setTimeout(() => {
|
|
94
|
+
output = `${output.trimEnd()}\ninstall killed after ${options.timeoutMs}ms`;
|
|
95
|
+
child.kill("SIGKILL");
|
|
96
|
+
}, options.timeoutMs)
|
|
97
|
+
: undefined;
|
|
98
|
+
child.stdout?.on("data", (chunk: Buffer) => (output += chunk.toString()));
|
|
99
|
+
child.stderr?.on("data", (chunk: Buffer) => (output += chunk.toString()));
|
|
100
|
+
child.once("error", (error) => {
|
|
101
|
+
if (timer) clearTimeout(timer);
|
|
102
|
+
resolvePromise({ command: rendered, exitCode: null, durationMs: Date.now() - started, outputTail: tail(output + "\n" + error.message) });
|
|
103
|
+
});
|
|
104
|
+
child.once("exit", (code) => {
|
|
105
|
+
if (timer) clearTimeout(timer);
|
|
106
|
+
resolvePromise({ command: rendered, exitCode: code, durationMs: Date.now() - started, outputTail: tail(output) });
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proposed Learnings -> one pull request (see ../../../CONTEXT.md: Proposed Learning, Accepted
|
|
3
|
+
* Learning; spec story 45). A Worker's Completion Report may carry reusable observations about the
|
|
4
|
+
* repository. They reach sibling Workers at once through Context Assembly, marked as proposed, but
|
|
5
|
+
* they become Accepted Learnings only when a human merges them into the repository's learnings
|
|
6
|
+
* file. At the end of a Run the Foreman gathers every Proposed Learning of the Run's Completion
|
|
7
|
+
* Reports, appends them to that file, each attributed to its Task and Hand-off, on a branch of its
|
|
8
|
+
* own in its own Workspace, pushes it through the guarded git runner and opens one draft pull
|
|
9
|
+
* request against the default branch, or adds to the one still open for the Initiative from an
|
|
10
|
+
* earlier Run. A Run with no Proposed Learning opens nothing.
|
|
11
|
+
*
|
|
12
|
+
* Where the file lives is the repository's to say: `foreman.learnings_file` in `.opsee/config`,
|
|
13
|
+
* `docs/learnings.md` by default. The Foreman only ever appends to it; what a human keeps, edits or
|
|
14
|
+
* drops there is the review. Two things keep the review the human's (ADR-0003): the Foreman writes
|
|
15
|
+
* only a branch of its own naming, never the head of some other pull request that happens to carry
|
|
16
|
+
* the title, and before adding to an open one it aligns its Workspace to what origin has, so an edit
|
|
17
|
+
* the reviewer pushed there is kept, not overwritten or fought with a rejected push.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { dirname, isAbsolute, join, normalize } from "node:path";
|
|
21
|
+
import { parseOpseeJson, readOpseeConfigFiles, readYamlBlock } from "../../opsee-config.js";
|
|
22
|
+
import type { RemoteApi, RemotePullRequest } from "../remote-api.js";
|
|
23
|
+
import { RECIPE_KEY } from "../run-recipe.js";
|
|
24
|
+
import type { TrackerTask } from "../tracker-adapter.js";
|
|
25
|
+
import type { Dispatch } from "./run.js";
|
|
26
|
+
import { count, oneLine } from "./text.js";
|
|
27
|
+
import { defaultBranchOf, type GitRunner, type Workspace, type WorkspaceManager } from "./workspace.js";
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_LEARNINGS_FILE = "docs/learnings.md";
|
|
30
|
+
|
|
31
|
+
/** The key under the `foreman` block of `.opsee/config` that names the learnings file. */
|
|
32
|
+
export const LEARNINGS_FILE_KEY = "learnings_file";
|
|
33
|
+
|
|
34
|
+
/** What a new learnings file starts with, so a reader knows what the entries are and where they
|
|
35
|
+
* come from before the first pull request lands. */
|
|
36
|
+
export const LEARNINGS_FILE_HEADER = [
|
|
37
|
+
"# Learnings",
|
|
38
|
+
"",
|
|
39
|
+
"Accepted Learnings about this repository: reusable observations Workers proposed in their",
|
|
40
|
+
"Completion Reports, gathered by the Foreman into a pull request at the end of each Run and",
|
|
41
|
+
"accepted by a human merging it. Every Worker reads this file at launch.",
|
|
42
|
+
"",
|
|
43
|
+
].join("\n");
|
|
44
|
+
|
|
45
|
+
/** One Proposed Learning with the Task it came from and the Hand-off, when there was one. */
|
|
46
|
+
export interface ProposedLearning {
|
|
47
|
+
body: string;
|
|
48
|
+
task: Pick<TrackerTask, "id" | "identifier" | "title" | "url">;
|
|
49
|
+
handOffUrl?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Every Proposed Learning of a Run's Completion Reports, in dispatch order. A turn that ended
|
|
53
|
+
* without a report has none; a `blocked` or `failed` report keeps its learnings, which are
|
|
54
|
+
* observations about the repository whatever the Task's fate. */
|
|
55
|
+
export function collectProposedLearnings(dispatches: Dispatch[]): ProposedLearning[] {
|
|
56
|
+
const learnings: ProposedLearning[] = [];
|
|
57
|
+
for (const d of dispatches) {
|
|
58
|
+
if (!d.report) continue;
|
|
59
|
+
const handOffUrl = d.handOff?.kind === "handed_off" ? d.handOff.pullRequest.url : d.report.handOff?.prUrl;
|
|
60
|
+
for (const body of d.report.proposedLearnings) {
|
|
61
|
+
const text = body.trim();
|
|
62
|
+
if (text) learnings.push({ body: text, task: d.task, handOffUrl });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return learnings;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The learnings file of the repository at `root`, relative to it: `foreman.learnings_file` in
|
|
69
|
+
* `.opsee/config` (the JSON copy first, then the YAML block), else the default. A repository with
|
|
70
|
+
* no config at all has the default; a value that leaves the repository is refused. */
|
|
71
|
+
export function learningsFileOf(root: string): string {
|
|
72
|
+
const files = readOpseeConfigFiles(root);
|
|
73
|
+
let raw: unknown;
|
|
74
|
+
if (files.json !== null) {
|
|
75
|
+
const block = parseOpseeJson(files.json)?.[RECIPE_KEY];
|
|
76
|
+
if (block && typeof block === "object") raw = (block as Record<string, unknown>)[LEARNINGS_FILE_KEY];
|
|
77
|
+
} else if (files.yaml !== null) {
|
|
78
|
+
raw = readYamlBlock(files.yaml, RECIPE_KEY)?.[LEARNINGS_FILE_KEY];
|
|
79
|
+
}
|
|
80
|
+
if (typeof raw !== "string" || raw.trim() === "") return DEFAULT_LEARNINGS_FILE;
|
|
81
|
+
const file = raw.trim();
|
|
82
|
+
const rel = normalize(file);
|
|
83
|
+
if (isAbsolute(file) || rel === ".." || rel.startsWith("../")) {
|
|
84
|
+
throw new Error(`foreman.${LEARNINGS_FILE_KEY} must name a file inside the repository, not "${file}"`);
|
|
85
|
+
}
|
|
86
|
+
return rel;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `Foreman: Proposed Learnings for Initiative 17`: the title the pull request is found by again
|
|
90
|
+
* on a later Run, so one Initiative has one open learnings pull request at a time. */
|
|
91
|
+
export function learningsTitle(initiativeId: number): string {
|
|
92
|
+
return `Foreman: Proposed Learnings for Initiative ${initiativeId}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Every branch the learnings step writes starts with this; `isLearningsBranch` is the check. */
|
|
96
|
+
export const LEARNINGS_BRANCH_PREFIX = "foreman/learnings-";
|
|
97
|
+
|
|
98
|
+
/** `foreman/learnings-17-20260907t101500z`: a branch per batch, never one that could be a Task's. */
|
|
99
|
+
export function learningsBranch(initiativeId: number, at: Date): string {
|
|
100
|
+
return `${LEARNINGS_BRANCH_PREFIX}${initiativeId}-${at.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "z").toLowerCase()}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Whether `branch` is one the Foreman named for this Initiative's learnings. A pull request found
|
|
104
|
+
* by title is the Foreman's only when its head is: anyone able to open a pull request can give it
|
|
105
|
+
* the title, and its head could be `main` or any protected branch the Foreman would then commit to
|
|
106
|
+
* and push (ADR-0003). */
|
|
107
|
+
export function isLearningsBranch(branch: string, initiativeId: number): boolean {
|
|
108
|
+
return branch.startsWith(`${LEARNINGS_BRANCH_PREFIX}${initiativeId}-`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function attribution(l: ProposedLearning): string {
|
|
112
|
+
const task = l.task.url ? `[${l.task.identifier}](${l.task.url})` : l.task.identifier;
|
|
113
|
+
const title = l.task.title.trim();
|
|
114
|
+
return `${task}${title ? ` "${title}"` : ""}${l.handOffUrl ? `, ${l.handOffUrl}` : ""}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** `2026-09-07 10:15 UTC`: when the batch was gathered, to the minute, so two Runs on one day
|
|
118
|
+
* (or a Run and a re-dispatch) get headings of their own. */
|
|
119
|
+
export function batchLabel(at: Date): string {
|
|
120
|
+
return `${at.toISOString().slice(0, 10)} ${at.toISOString().slice(11, 16)} UTC`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function batchHeading(initiativeId: number, at: Date): string {
|
|
124
|
+
return `## Initiative ${initiativeId}, Run of ${batchLabel(at)}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The entries appended to the learnings file for one Run: one heading for the batch, one bullet
|
|
128
|
+
* per learning with the Task and its Hand-off after it, so a reader of the file alone can tell
|
|
129
|
+
* where each came from and a human reviewing the pull request can open the work it came out of. */
|
|
130
|
+
export function renderLearnings(learnings: ProposedLearning[], initiativeId: number, at: Date): string {
|
|
131
|
+
const lines = learnings.map((l) => `- ${oneLine(l.body)} (${attribution(l)})`);
|
|
132
|
+
return `\n${batchHeading(initiativeId, at)}\n\n${lines.join("\n")}\n`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** How many batches and learnings the pull request's branch carries in the Foreman's own commits
|
|
136
|
+
* (`tallyOf`), so the body can say what the whole proposal is, not only the latest batch. */
|
|
137
|
+
export interface LearningsTally {
|
|
138
|
+
batches: number;
|
|
139
|
+
total: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const COMMIT_SUBJECT = /^docs: (\d+) Proposed Learnings? from the Foreman's Run on Initiative (\d+)$/;
|
|
143
|
+
|
|
144
|
+
function commitSubject(appended: number, initiativeId: number): string {
|
|
145
|
+
return `docs: ${count(appended, "Proposed Learning")} from the Foreman's Run on Initiative ${initiativeId}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Reads the Foreman's own commits out of the branch's subjects (`git log --format=%s`), the way
|
|
149
|
+
* `commitSubject` writes them; a human's commit on the branch is not a batch. */
|
|
150
|
+
export function tallyOf(subjects: string[], initiativeId: number): LearningsTally {
|
|
151
|
+
const tally: LearningsTally = { batches: 0, total: 0 };
|
|
152
|
+
for (const subject of subjects) {
|
|
153
|
+
const m = COMMIT_SUBJECT.exec(subject.trim());
|
|
154
|
+
if (!m || Number(m[2]) !== initiativeId) continue;
|
|
155
|
+
tally.batches++;
|
|
156
|
+
tally.total += Number(m[1]);
|
|
157
|
+
}
|
|
158
|
+
return tally;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The pull request body: what the whole branch proposes so far, the latest batch in full, by
|
|
162
|
+
* which Task, and what merging means. Earlier batches are not repeated here, since the reviewer
|
|
163
|
+
* may have edited or dropped them on the branch already; the diff of the file is the proposal. */
|
|
164
|
+
export function learningsPullRequestBody(learnings: ProposedLearning[], initiativeId: number, file: string, at: Date, tally: LearningsTally): string {
|
|
165
|
+
const lines = learnings.map((l) => `- ${oneLine(l.body)}\n - from ${attribution(l)}`);
|
|
166
|
+
const sofar =
|
|
167
|
+
tally.batches > 1
|
|
168
|
+
? `${count(tally.total, "Proposed Learning")} in ${count(tally.batches, "batch", "batches")} so far, one per Run; the diff of \`${file}\` on this branch is the whole proposal, and only the latest batch is listed below.`
|
|
169
|
+
: `${count(learnings.length, "Proposed Learning")} in one batch so far; a later Run of the Initiative adds its batch to this branch while the pull request is open.`;
|
|
170
|
+
return [
|
|
171
|
+
`Draft by the Foreman: Proposed Learnings from the Completion Reports of Runs on Initiative ${initiativeId}, appended to \`${file}\`. ${sofar}`,
|
|
172
|
+
"",
|
|
173
|
+
"A Worker proposed each of these; none is trusted until this merges. Keep what holds, edit what is imprecise, drop the rest, then merge: what is left becomes an Accepted Learning every Worker reads at launch. Edits pushed to this branch are kept when the next batch is added.",
|
|
174
|
+
"",
|
|
175
|
+
`## Latest batch: Run of ${batchLabel(at)} (${count(learnings.length, "Proposed Learning")})`,
|
|
176
|
+
"",
|
|
177
|
+
lines.join("\n"),
|
|
178
|
+
].join("\n");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export type LearningsResult =
|
|
182
|
+
| { kind: "opened" | "updated"; pullRequest: RemotePullRequest; count: number; file: string; branch: string; headSha: string }
|
|
183
|
+
| { kind: "none" }
|
|
184
|
+
/** Every learning of the batch was in the file already (a re-dispatched Task, a Run served twice). */
|
|
185
|
+
| { kind: "repeated"; count: number; file: string }
|
|
186
|
+
| { kind: "failed"; count: number; reason: string };
|
|
187
|
+
|
|
188
|
+
export interface LearningsInput {
|
|
189
|
+
initiativeId: number;
|
|
190
|
+
learnings: ProposedLearning[];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export type LearningsFn = (input: LearningsInput) => Promise<LearningsResult>;
|
|
194
|
+
|
|
195
|
+
export interface LearningsDeps {
|
|
196
|
+
/** Must be the guarded runner (workspace.ts `guardedGit`): the push relies on it. */
|
|
197
|
+
git: GitRunner;
|
|
198
|
+
remote: RemoteApi;
|
|
199
|
+
repoRoot: string;
|
|
200
|
+
workspaces: Pick<WorkspaceManager, "create">;
|
|
201
|
+
/** The learnings file, relative to the repository root (`learningsFileOf`). */
|
|
202
|
+
file: string;
|
|
203
|
+
log?: (line: string) => void;
|
|
204
|
+
now?: () => Date;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function message(error: unknown): string {
|
|
208
|
+
return error instanceof Error ? error.message : String(error);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A learnings step that submits nothing and says why: what the Run gets when the repository's
|
|
212
|
+
* `foreman.learnings_file` is unusable, so the Run itself goes on and its tally names the cause. */
|
|
213
|
+
export function learningsRefusedWith(reason: string): LearningsFn {
|
|
214
|
+
return async ({ learnings }) => (learnings.length === 0 ? { kind: "none" } : { kind: "failed", count: learnings.length, reason });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function normalized(text: string): string {
|
|
218
|
+
return text.replace(/\s+/g, " ").trim().toLowerCase();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function escapeRegExp(text: string): string {
|
|
222
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Whether the file already carries this learning: a bullet with the same body (whitespace and
|
|
226
|
+
* case aside) attributed to the same Task. A re-dispatched Task, or a Run served twice, reports
|
|
227
|
+
* the same observation again; the file gets it once. */
|
|
228
|
+
export function alreadyInLearningsFile(content: string, learning: ProposedLearning): boolean {
|
|
229
|
+
const body = normalized(learning.body);
|
|
230
|
+
const identifier = new RegExp(`(^|[^a-z0-9])${escapeRegExp(normalized(learning.task.identifier))}([^a-z0-9]|$)`);
|
|
231
|
+
return content
|
|
232
|
+
.split("\n")
|
|
233
|
+
.filter((line) => line.startsWith("- "))
|
|
234
|
+
.map(normalized)
|
|
235
|
+
.some((line) => line.includes(body) && identifier.test(line));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface AppendedLearnings {
|
|
239
|
+
/** The file's content after the append (unchanged when nothing was appended). */
|
|
240
|
+
content: string;
|
|
241
|
+
/** The learnings actually added, in order; the batch is dropped when this is empty. */
|
|
242
|
+
appended: ProposedLearning[];
|
|
243
|
+
/** The learnings already in the file, left alone. */
|
|
244
|
+
skipped: ProposedLearning[];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Appends the batch to the learnings file in the Workspace, creating it with the header when it
|
|
248
|
+
* is not there yet, skipping every learning the file already has and writing nothing when none is
|
|
249
|
+
* new. */
|
|
250
|
+
export function appendToLearningsFile(workspace: Pick<Workspace, "path">, file: string, learnings: ProposedLearning[], initiativeId: number, at: Date): AppendedLearnings {
|
|
251
|
+
const path = join(workspace.path, file);
|
|
252
|
+
const existing = existsSync(path) ? readFileSync(path, "utf8") : LEARNINGS_FILE_HEADER;
|
|
253
|
+
const appended: ProposedLearning[] = [];
|
|
254
|
+
const skipped: ProposedLearning[] = [];
|
|
255
|
+
for (const l of learnings) (alreadyInLearningsFile(existing, l) || appended.some((a) => sameLearning(a, l)) ? skipped : appended).push(l);
|
|
256
|
+
if (appended.length === 0) return { content: existing, appended, skipped };
|
|
257
|
+
const content = `${existing.replace(/\s*$/, "")}\n${renderLearnings(appended, initiativeId, at)}`;
|
|
258
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
259
|
+
writeFileSync(path, content);
|
|
260
|
+
return { content, appended, skipped };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function sameLearning(a: ProposedLearning, b: ProposedLearning): boolean {
|
|
264
|
+
return normalized(a.body) === normalized(b.body) && normalized(a.task.identifier) === normalized(b.task.identifier);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The step the Run loop calls once, after its last dispatch (run.ts). */
|
|
268
|
+
export function learningsWith(deps: LearningsDeps): LearningsFn {
|
|
269
|
+
const log = deps.log ?? (() => {});
|
|
270
|
+
const now = deps.now ?? (() => new Date());
|
|
271
|
+
return async ({ initiativeId, learnings }) => {
|
|
272
|
+
if (learnings.length === 0) {
|
|
273
|
+
log(`learnings: no Proposed Learning in this Run's Completion Reports; no pull request`);
|
|
274
|
+
return { kind: "none" };
|
|
275
|
+
}
|
|
276
|
+
const { git, remote, repoRoot, file } = deps;
|
|
277
|
+
const failed = (reason: string): LearningsResult => {
|
|
278
|
+
log(`learnings: ${reason}`);
|
|
279
|
+
return { kind: "failed", count: learnings.length, reason };
|
|
280
|
+
};
|
|
281
|
+
const at = now();
|
|
282
|
+
const title = learningsTitle(initiativeId);
|
|
283
|
+
|
|
284
|
+
const base = await defaultBranchOf(git, repoRoot);
|
|
285
|
+
if (!base) return failed("origin has no default branch the Foreman could target with a pull request");
|
|
286
|
+
|
|
287
|
+
// One open learnings pull request per Initiative: a later Run adds to it rather than opening a
|
|
288
|
+
// second one for the human to reconcile. Only one against the default branch, from a branch of
|
|
289
|
+
// the Foreman's own naming, is the Foreman's: the title is anyone's to give, the branch is not.
|
|
290
|
+
let existing: RemotePullRequest | undefined;
|
|
291
|
+
try {
|
|
292
|
+
existing = await remote.findPullRequestByTitle(title);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return failed(`could not look for an open learnings pull request: ${message(error)}`);
|
|
295
|
+
}
|
|
296
|
+
if (existing && existing.baseBranch !== base) {
|
|
297
|
+
log(`learnings: the open pull request ${existing.url} titled "${title}" targets "${existing.baseBranch}", not "${base}"; opening a new one`);
|
|
298
|
+
existing = undefined;
|
|
299
|
+
}
|
|
300
|
+
if (existing && !isLearningsBranch(existing.headBranch, initiativeId)) {
|
|
301
|
+
log(`learnings: the open pull request ${existing.url} titled "${title}" comes from "${existing.headBranch}", not a ${LEARNINGS_BRANCH_PREFIX}${initiativeId}-* branch of the Foreman's; leaving it alone and opening a new one (ADR-0003)`);
|
|
302
|
+
existing = undefined;
|
|
303
|
+
}
|
|
304
|
+
const branch = existing?.headBranch ?? learningsBranch(initiativeId, at);
|
|
305
|
+
|
|
306
|
+
let workspace: Workspace;
|
|
307
|
+
try {
|
|
308
|
+
workspace = await deps.workspaces.create(branch);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
return failed(`could not create a Workspace on ${branch}: ${message(error)}`);
|
|
311
|
+
}
|
|
312
|
+
if (existing) {
|
|
313
|
+
// The branch is the reviewer's as much as the Foreman's: whatever was pushed to it since the
|
|
314
|
+
// Workspace was made (an entry edited or dropped) is what this batch goes after, or the push
|
|
315
|
+
// below would be rejected for good. A reset cannot reach origin; the guard allows it.
|
|
316
|
+
try {
|
|
317
|
+
await git(["fetch", "--quiet", "origin", branch], workspace.path);
|
|
318
|
+
await git(["reset", "--quiet", "--hard", `origin/${branch}`], workspace.path);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
return failed(`could not align the Workspace on ${branch} with origin before adding to ${existing.url}: ${message(error)}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const { content, appended, skipped } = appendToLearningsFile(workspace, file, learnings, initiativeId, at);
|
|
325
|
+
if (skipped.length > 0) log(`learnings: ${count(skipped.length, "entry", "entries")} already in ${file} on ${branch}; not repeated`);
|
|
326
|
+
if (appended.length === 0) {
|
|
327
|
+
log(`learnings: every Proposed Learning of this Run is in ${file} on ${branch} already; nothing to submit`);
|
|
328
|
+
return { kind: "repeated", count: learnings.length, file };
|
|
329
|
+
}
|
|
330
|
+
log(`learnings: appended ${count(appended.length, "entry", "entries")} to ${file} in ${workspace.path} (${content.length} chars)`);
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
await git(["add", "--", file], workspace.path);
|
|
334
|
+
// The Foreman's own commit: never signed interactively, which would hold an unattended Run,
|
|
335
|
+
// and past the repository's commit hooks, which are written for a person at a terminal.
|
|
336
|
+
await git(["-c", "commit.gpgsign=false", "commit", "--quiet", "--no-verify", "-m", commitSubject(appended.length, initiativeId)], workspace.path);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
return failed(`could not commit ${file} on ${branch}: ${message(error)}`);
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
await git(["push", "--set-upstream", "origin", `${branch}:${branch}`], workspace.path);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
return failed(`push of ${branch} to origin failed: ${message(error)}`);
|
|
344
|
+
}
|
|
345
|
+
const headSha = await git(["rev-parse", branch], workspace.path);
|
|
346
|
+
log(`learnings: pushed ${branch} (${headSha.slice(0, 12)}) to origin`);
|
|
347
|
+
|
|
348
|
+
let tally: LearningsTally = { batches: 1, total: appended.length };
|
|
349
|
+
try {
|
|
350
|
+
tally = tallyOf((await git(["log", "--format=%s", `origin/${base}..HEAD`], workspace.path)).split("\n"), initiativeId);
|
|
351
|
+
} catch {
|
|
352
|
+
/* the body then speaks of this batch alone */
|
|
353
|
+
}
|
|
354
|
+
const body = learningsPullRequestBody(appended, initiativeId, file, at, tally);
|
|
355
|
+
try {
|
|
356
|
+
if (existing) {
|
|
357
|
+
const pullRequest = await remote.updatePullRequest(existing.number, { title, body });
|
|
358
|
+
log(`learnings: added to the open pull request ${pullRequest.url}`);
|
|
359
|
+
return { kind: "updated", pullRequest, count: appended.length, file, branch, headSha };
|
|
360
|
+
}
|
|
361
|
+
const pullRequest = await remote.openDraftPullRequest({ headBranch: branch, baseBranch: base, title, body });
|
|
362
|
+
log(`learnings: opened draft pull request ${pullRequest.url} against ${base} with ${count(appended.length, "Proposed Learning")}`);
|
|
363
|
+
return { kind: "opened", pullRequest, count: appended.length, file, branch, headSha };
|
|
364
|
+
} catch (error) {
|
|
365
|
+
return failed(`${branch} is pushed, but the pull request against ${base} failed: ${message(error)}`);
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outbox-backed Tracker (story 50, ADR-0009): a Tracker Adapter that behaves like the one it
|
|
3
|
+
* wraps except that a Run Record write which cannot reach the backend is queued in the Process
|
|
4
|
+
* Table's outbox instead of failing the turn, and drained, in order, once the backend answers
|
|
5
|
+
* again. Every other operation passes straight through: the board moves and comments are not the
|
|
6
|
+
* commit, the Run Record write is, and that is the one that must never be lost.
|
|
7
|
+
*
|
|
8
|
+
* Order is kept two ways: a new append first drains what is already queued for the Initiative
|
|
9
|
+
* (and joins the queue itself when that fails), and `drain` stops at the first failure. What the
|
|
10
|
+
* loop reads back through `readRunRecord` includes the queued batches, so attempt numbering stays
|
|
11
|
+
* right while the backend is away. A queued batch the backend later refuses outright (the
|
|
12
|
+
* Initiative is gone, the payload is rejected) is dropped and logged rather than left to hold
|
|
13
|
+
* every batch behind it.
|
|
14
|
+
*
|
|
15
|
+
* Every operation that touches the outbox is taken one at a time (`serialize`). Slots
|
|
16
|
+
* (core/scheduler.ts) made dispatch concurrent, and `drain` is read-then-send-then-delete with
|
|
17
|
+
* nothing under it to serialise: two of them at once would read the same pending batch and both
|
|
18
|
+
* send it. A duplicated `dispatch` event is not a cosmetic repeat — `attempt = priorDispatches + 1`
|
|
19
|
+
* counts those events, so every later attempt on that Task takes a number that skips, and
|
|
20
|
+
* `finishSettlement` and the Verdict lookups, which match an attempt by number, then look at the
|
|
21
|
+
* wrong one.
|
|
22
|
+
*
|
|
23
|
+
* **Serialising is not enough on its own, which is what the idempotency key is for.** The codes this
|
|
24
|
+
* queues on include `DeadlineExceeded` and `Unknown` — the two where the backend may have committed
|
|
25
|
+
* the write and only the answer was lost. Nothing local can tell that apart from a write that never
|
|
26
|
+
* landed, so the batch is queued either way and re-sent, and without a key the re-send appends the
|
|
27
|
+
* same events a second time: exactly the duplicate above, arrived by a route no amount of
|
|
28
|
+
* in-process locking closes. Every event is therefore stamped with a key *before the first send*,
|
|
29
|
+
* and the backend refuses to store a key it already has under that Run. The key rides inside the
|
|
30
|
+
* event, so the outbox stores it with everything else and a re-send is the same events under the
|
|
31
|
+
* same keys, however many times it takes.
|
|
32
|
+
*/
|
|
33
|
+
import { randomUUID } from "node:crypto";
|
|
34
|
+
import { create } from "@bufbuild/protobuf";
|
|
35
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
36
|
+
import { RunEventSchema, type RunEvent, type RunEventInput } from "@opsee/mcp-server/gen/api/v1/initiative_pb.js";
|
|
37
|
+
import type { RunRecord, RunRecordQuery, TrackerAdapter } from "../tracker-adapter.js";
|
|
38
|
+
import type { DrainResult, ProcessTableApi } from "./process-table.js";
|
|
39
|
+
import { errorMessage } from "../worker-process.js";
|
|
40
|
+
|
|
41
|
+
/** Codes that mean the backend could not be reached or did not answer, so the write may succeed
|
|
42
|
+
* later. A non-Connect error is a transport failure. */
|
|
43
|
+
const QUEUEABLE = new Set<Code>([Code.Unavailable, Code.DeadlineExceeded, Code.Unknown, Code.Internal, Code.Aborted, Code.ResourceExhausted]);
|
|
44
|
+
|
|
45
|
+
/** Codes that mean the backend has looked at the batch and will never take it: the events are
|
|
46
|
+
* malformed, or what they point at is gone. Only these are dropped from the outbox. Everything
|
|
47
|
+
* else in between (a token that expired while the daemon idled, a permission pulled, a proxy
|
|
48
|
+
* answering 404 for a backend that is mid-deploy) keeps the batch waiting: dropping it there
|
|
49
|
+
* would lose the Run Record's own commit for an attempt whose row is already gone. */
|
|
50
|
+
const REFUSED = new Set<Code>([Code.InvalidArgument, Code.NotFound, Code.FailedPrecondition, Code.OutOfRange]);
|
|
51
|
+
|
|
52
|
+
export function isQueueable(error: unknown): boolean {
|
|
53
|
+
if (error instanceof ConnectError) return QUEUEABLE.has(error.code);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Whether a queued batch should be dropped rather than retried. */
|
|
58
|
+
export function isRefused(error: unknown): boolean {
|
|
59
|
+
return error instanceof ConnectError && REFUSED.has(error.code);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type Outbox = Pick<ProcessTableApi, "enqueue" | "pending" | "drain">;
|
|
63
|
+
|
|
64
|
+
export class OutboxTracker implements TrackerAdapter {
|
|
65
|
+
constructor(
|
|
66
|
+
private readonly inner: TrackerAdapter,
|
|
67
|
+
private readonly outbox: Outbox,
|
|
68
|
+
private readonly log: (line: string) => void,
|
|
69
|
+
/** How a key is minted. Injected for the tests alone, the way `now` and `sleep` are elsewhere:
|
|
70
|
+
* a key has to be unpredictable in life and fixed in a test, and nothing else about it matters. */
|
|
71
|
+
private readonly newKey: () => string = randomUUID,
|
|
72
|
+
) {}
|
|
73
|
+
|
|
74
|
+
/** Outbox work in flight: every call that reads or writes the outbox links onto this chain, so
|
|
75
|
+
* only one of them is inside it at a time. In-process only, like the Workspace queue: it is the
|
|
76
|
+
* Slots of one Run that made this concurrent, and two Foreman processes sharing a Process Table
|
|
77
|
+
* would still need the database to keep them apart. */
|
|
78
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
79
|
+
|
|
80
|
+
/** Runs `work` once whatever outbox work is already going has finished, however it finished: a
|
|
81
|
+
* failed drain must not poison the calls behind it. */
|
|
82
|
+
private serialize<T>(work: () => Promise<T>): Promise<T> {
|
|
83
|
+
const mine = this.queue.then(work, work);
|
|
84
|
+
this.queue = mine.then(
|
|
85
|
+
() => undefined,
|
|
86
|
+
() => undefined,
|
|
87
|
+
);
|
|
88
|
+
return mine;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
listReadyTasks: TrackerAdapter["listReadyTasks"] = (id) => this.inner.listReadyTasks(id);
|
|
92
|
+
getTask: TrackerAdapter["getTask"] = (id) => this.inner.getTask(id);
|
|
93
|
+
addComment: TrackerAdapter["addComment"] = (id, body) => this.inner.addComment(id, body);
|
|
94
|
+
addInitiativeComment: TrackerAdapter["addInitiativeComment"] = (id, body) => this.inner.addInitiativeComment(id, body);
|
|
95
|
+
moveTask: TrackerAdapter["moveTask"] = (id, lifecycle) => this.inner.moveTask(id, lifecycle);
|
|
96
|
+
attachLabel: TrackerAdapter["attachLabel"] = (id, label) => this.inner.attachLabel(id, label);
|
|
97
|
+
detachLabel: TrackerAdapter["detachLabel"] = (id, label) => this.inner.detachLabel(id, label);
|
|
98
|
+
ensureLabel: TrackerAdapter["ensureLabel"] = (id, label) => this.inner.ensureLabel(id, label);
|
|
99
|
+
createTask: TrackerAdapter["createTask"] = (input) => this.inner.createTask(input);
|
|
100
|
+
projectRepositories: TrackerAdapter["projectRepositories"] = (id) => this.inner.projectRepositories(id);
|
|
101
|
+
linkPullRequest: TrackerAdapter["linkPullRequest"] = (id, pr) => this.inner.linkPullRequest(id, pr);
|
|
102
|
+
addMemory: TrackerAdapter["addMemory"] = (id, entry) => this.inner.addMemory(id, entry);
|
|
103
|
+
listMemory: TrackerAdapter["listMemory"] = (id, query) => this.inner.listMemory(id, query);
|
|
104
|
+
getInitiativeContext: TrackerAdapter["getInitiativeContext"] = (id) => this.inner.getInitiativeContext(id);
|
|
105
|
+
|
|
106
|
+
/** Sends what is queued for the Initiative (or everything), oldest first, stopping at the first
|
|
107
|
+
* failure; the Run loop calls this at the top of each tick, while dispatches are in flight. */
|
|
108
|
+
drain(initiativeId?: number): Promise<DrainResult> {
|
|
109
|
+
return this.serialize(() => this.drainNow(initiativeId));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The drain itself. Only ever called with the queue held: from `serialize`, or from `appendNow`,
|
|
113
|
+
* which already holds it. */
|
|
114
|
+
private async drainNow(initiativeId?: number): Promise<DrainResult> {
|
|
115
|
+
const filter = initiativeId === undefined ? {} : { initiativeId };
|
|
116
|
+
if (this.outbox.pending(filter).length === 0) return { sent: 0, remaining: 0, discarded: [] };
|
|
117
|
+
const result = await this.outbox.drain((id, events) => this.inner.appendRunEvents(id, events), { ...filter, discard: isRefused });
|
|
118
|
+
for (const batch of result.discarded) {
|
|
119
|
+
const kinds = batch.events.map((e) => e.payload?.payload.case ?? "event").join(", ");
|
|
120
|
+
this.log(`outbox: batch ${batch.id} (${kinds}) for Initiative ${batch.initiativeId} dropped: the backend refused it, so it would never be taken`);
|
|
121
|
+
}
|
|
122
|
+
if (result.sent) this.log(`outbox: ${result.sent} queued Run Record ${result.sent === 1 ? "batch" : "batches"} delivered${result.remaining ? `, ${result.remaining} still waiting` : ""}`);
|
|
123
|
+
if (result.error) this.log(`outbox: ${result.remaining} ${result.remaining === 1 ? "batch" : "batches"} waiting; the backend did not take the next one: ${result.error.message}`);
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
appendRunEvents(initiativeId: number, events: RunEventInput[]): Promise<RunEvent[]> {
|
|
128
|
+
return this.serialize(() => this.appendNow(initiativeId, events));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Stamps an idempotency key on every event that has none, in place.
|
|
133
|
+
*
|
|
134
|
+
* Before the first send, not on the way into the outbox: the duplicate this exists to stop is the
|
|
135
|
+
* one where that first send *succeeded* and its answer was lost, and a key minted after the
|
|
136
|
+
* failure would be a different key from the one the backend already stored. The same objects go
|
|
137
|
+
* on to be queued, so the re-send carries the keys the first attempt used.
|
|
138
|
+
*
|
|
139
|
+
* An event that already has one keeps it, so a caller that mints its own is not overruled.
|
|
140
|
+
*/
|
|
141
|
+
private keyed(events: RunEventInput[]): RunEventInput[] {
|
|
142
|
+
for (const event of events) if (!event.idempotencyKey) event.idempotencyKey = this.newKey();
|
|
143
|
+
return events;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async appendNow(initiativeId: number, events: RunEventInput[]): Promise<RunEvent[]> {
|
|
147
|
+
this.keyed(events);
|
|
148
|
+
// What is already queued goes first, or this batch joins the queue behind it. `drainNow`, since
|
|
149
|
+
// this call holds the queue already and asking for it again would wait on itself.
|
|
150
|
+
if (this.outbox.pending({ initiativeId }).length > 0) {
|
|
151
|
+
const drained = await this.drainNow(initiativeId);
|
|
152
|
+
if (drained.error) return this.queueBatch(initiativeId, events, drained.error);
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
return await this.inner.appendRunEvents(initiativeId, events);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (!isQueueable(error)) throw error;
|
|
158
|
+
return this.queueBatch(initiativeId, events, error);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private queueBatch(initiativeId: number, events: RunEventInput[], error: unknown): RunEvent[] {
|
|
163
|
+
const id = this.outbox.enqueue(initiativeId, events);
|
|
164
|
+
const kinds = events.map((e) => e.payload?.payload.case ?? "event").join(", ");
|
|
165
|
+
this.log(`outbox: Run Record write queued as batch ${id} (${kinds}); the backend did not take it: ${errorMessage(error)}`);
|
|
166
|
+
return events.map((e) => placeholder(initiativeId, e));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The backend's Run Record followed by the queued batches that match, so a count of dispatches
|
|
170
|
+
* or a check for an attempt event sees what is committed locally and not yet delivered. Taken
|
|
171
|
+
* one at a time with the rest: a drain that delivered a batch between the read of the backend's
|
|
172
|
+
* record and the read of the outbox would leave that batch in neither, and the count of
|
|
173
|
+
* dispatches an attempt number is made of would be one short. */
|
|
174
|
+
readRunRecord(initiativeId: number, query: RunRecordQuery = {}): Promise<RunRecord> {
|
|
175
|
+
return this.serialize(() => this.readNow(initiativeId, query));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private async readNow(initiativeId: number, query: RunRecordQuery): Promise<RunRecord> {
|
|
179
|
+
const record = await this.inner.readRunRecord(initiativeId, query);
|
|
180
|
+
const queued = this.outbox
|
|
181
|
+
.pending({ initiativeId })
|
|
182
|
+
.flatMap((b) => b.events)
|
|
183
|
+
.filter((e) => (query.taskId === undefined || e.taskId === query.taskId) && (!query.kinds || query.kinds.includes(e.payload?.payload.case ?? "")))
|
|
184
|
+
.map((e) => placeholder(initiativeId, e));
|
|
185
|
+
return queued.length ? { run: record.run, events: [...record.events, ...queued] } : record;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A queued event as the loop sees it: id 0 says it has no server id yet. */
|
|
190
|
+
function placeholder(initiativeId: number, input: RunEventInput): RunEvent {
|
|
191
|
+
return create(RunEventSchema, { id: 0, runId: 0, initiativeId, kind: input.payload?.payload.case ?? "", taskId: input.taskId, payload: input.payload, isAgent: true });
|
|
192
|
+
}
|