patchrelay 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +35 -7
- package/dist/cli/commands/feed.js +53 -0
- package/dist/cli/commands/issues.js +9 -7
- package/dist/cli/data.js +82 -2
- package/dist/cli/formatters/text.js +53 -4
- package/dist/cli/index.js +101 -5
- package/dist/db/migrations.js +15 -0
- package/dist/db/operator-feed-store.js +72 -0
- package/dist/db.js +3 -0
- package/dist/http.js +52 -0
- package/dist/operator-feed.js +85 -0
- package/dist/service-stage-finalizer.js +37 -2
- package/dist/service-stage-runner.js +43 -2
- package/dist/service-webhook-processor.js +105 -3
- package/dist/service.js +12 -3
- package/dist/stage-lifecycle-publisher.js +31 -1
- package/dist/stage-turn-input-dispatcher.js +5 -3
- package/dist/webhook-agent-session-handler.js +51 -1
- package/dist/webhook-comment-handler.js +55 -3
- package/package.json +3 -2
package/dist/build-info.json
CHANGED
package/dist/cli/args.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export const KNOWN_COMMANDS = new Set([
|
|
2
|
+
"version",
|
|
2
3
|
"serve",
|
|
3
4
|
"inspect",
|
|
4
5
|
"live",
|
|
@@ -13,10 +14,12 @@ export const KNOWN_COMMANDS = new Set([
|
|
|
13
14
|
"project",
|
|
14
15
|
"connect",
|
|
15
16
|
"installations",
|
|
17
|
+
"feed",
|
|
16
18
|
"install-service",
|
|
17
19
|
"restart-service",
|
|
18
20
|
"help",
|
|
19
21
|
]);
|
|
22
|
+
const ISSUE_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
|
|
20
23
|
export function parseArgs(argv) {
|
|
21
24
|
const positionals = [];
|
|
22
25
|
const flags = new Map();
|
|
@@ -47,13 +50,16 @@ export function parseArgs(argv) {
|
|
|
47
50
|
}
|
|
48
51
|
export function resolveCommand(parsed) {
|
|
49
52
|
const requestedCommand = parsed.positionals[0];
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
if (!requestedCommand) {
|
|
54
|
+
return { command: "help", commandArgs: [] };
|
|
55
|
+
}
|
|
56
|
+
if (KNOWN_COMMANDS.has(requestedCommand)) {
|
|
57
|
+
return { command: requestedCommand, commandArgs: parsed.positionals.slice(1) };
|
|
58
|
+
}
|
|
59
|
+
if (ISSUE_KEY_PATTERN.test(requestedCommand)) {
|
|
60
|
+
return { command: "inspect", commandArgs: parsed.positionals };
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`Unknown command: ${requestedCommand}. Run \`patchrelay help\`.`);
|
|
57
63
|
}
|
|
58
64
|
export function getStageFlag(value) {
|
|
59
65
|
if (typeof value !== "string") {
|
|
@@ -71,3 +77,25 @@ export function parseCsvFlag(value) {
|
|
|
71
77
|
.map((entry) => entry.trim())
|
|
72
78
|
.filter(Boolean);
|
|
73
79
|
}
|
|
80
|
+
export function assertKnownFlags(parsed, command, allowedFlags) {
|
|
81
|
+
const allowed = new Set(allowedFlags);
|
|
82
|
+
const unknownFlags = [...parsed.flags.keys()].filter((flag) => !allowed.has(flag)).sort();
|
|
83
|
+
if (unknownFlags.length === 0) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
throw new Error(`Unknown flag${unknownFlags.length === 1 ? "" : "s"} for ${command}: ${unknownFlags.map((flag) => `--${flag}`).join(", ")}`);
|
|
87
|
+
}
|
|
88
|
+
export function parsePositiveIntegerFlag(value, flagName) {
|
|
89
|
+
if (typeof value !== "string") {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
const trimmed = value.trim();
|
|
93
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
94
|
+
throw new Error(`${flagName} must be a positive integer.`);
|
|
95
|
+
}
|
|
96
|
+
const parsed = Number(trimmed);
|
|
97
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
98
|
+
throw new Error(`${flagName} must be a positive integer.`);
|
|
99
|
+
}
|
|
100
|
+
return parsed;
|
|
101
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { formatJson } from "../formatters/json.js";
|
|
2
|
+
import { formatOperatorFeed, formatOperatorFeedEvent } from "../formatters/text.js";
|
|
3
|
+
import { writeOutput } from "../output.js";
|
|
4
|
+
function parseLimit(value) {
|
|
5
|
+
if (typeof value !== "string") {
|
|
6
|
+
return 50;
|
|
7
|
+
}
|
|
8
|
+
const trimmed = value.trim();
|
|
9
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
10
|
+
throw new Error("--limit must be a positive integer.");
|
|
11
|
+
}
|
|
12
|
+
const parsed = Number(trimmed);
|
|
13
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
14
|
+
throw new Error("--limit must be a positive integer.");
|
|
15
|
+
}
|
|
16
|
+
return parsed;
|
|
17
|
+
}
|
|
18
|
+
export async function handleFeedCommand(params) {
|
|
19
|
+
const limit = parseLimit(params.parsed.flags.get("limit"));
|
|
20
|
+
const follow = params.parsed.flags.get("follow") === true;
|
|
21
|
+
const issueFlag = params.parsed.flags.get("issue");
|
|
22
|
+
const projectFlag = params.parsed.flags.get("project");
|
|
23
|
+
if (issueFlag === true) {
|
|
24
|
+
throw new Error("--issue requires a value.");
|
|
25
|
+
}
|
|
26
|
+
if (projectFlag === true) {
|
|
27
|
+
throw new Error("--project requires a value.");
|
|
28
|
+
}
|
|
29
|
+
const issueKey = typeof issueFlag === "string" ? issueFlag.trim() || undefined : undefined;
|
|
30
|
+
const projectId = typeof projectFlag === "string" ? projectFlag.trim() || undefined : undefined;
|
|
31
|
+
const query = {
|
|
32
|
+
limit,
|
|
33
|
+
...(issueKey ? { issueKey } : {}),
|
|
34
|
+
...(projectId ? { projectId } : {}),
|
|
35
|
+
};
|
|
36
|
+
if (!follow) {
|
|
37
|
+
const result = await params.data.listOperatorFeed(query);
|
|
38
|
+
writeOutput(params.stdout, params.json
|
|
39
|
+
? formatJson(result)
|
|
40
|
+
: formatOperatorFeed(result, { color: "isTTY" in params.stdout && params.stdout.isTTY === true }));
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
if (params.json) {
|
|
44
|
+
await params.data.followOperatorFeed((event) => {
|
|
45
|
+
writeOutput(params.stdout, formatJson(event));
|
|
46
|
+
}, query);
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
await params.data.followOperatorFeed((event) => {
|
|
50
|
+
writeOutput(params.stdout, formatOperatorFeedEvent(event, { color: "isTTY" in params.stdout && params.stdout.isTTY === true }));
|
|
51
|
+
}, query);
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
-
import { getStageFlag } from "../args.js";
|
|
2
|
+
import { getStageFlag, parsePositiveIntegerFlag } from "../args.js";
|
|
3
3
|
import { formatJson } from "../formatters/json.js";
|
|
4
4
|
import { formatEvents, formatInspect, formatList, formatLive, formatOpen, formatReport, formatRetry, formatWorktree } from "../formatters/text.js";
|
|
5
5
|
import { buildOpenCommand } from "../interactive.js";
|
|
@@ -45,8 +45,9 @@ export async function handleReportCommand(params) {
|
|
|
45
45
|
if (stage) {
|
|
46
46
|
reportOptions.stage = stage;
|
|
47
47
|
}
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
const stageRunId = parsePositiveIntegerFlag(params.parsed.flags.get("stage-run"), "--stage-run");
|
|
49
|
+
if (stageRunId !== undefined) {
|
|
50
|
+
reportOptions.stageRunId = stageRunId;
|
|
50
51
|
}
|
|
51
52
|
const result = params.data.report(issueKey, reportOptions);
|
|
52
53
|
if (!result) {
|
|
@@ -62,7 +63,7 @@ export async function handleEventsCommand(params) {
|
|
|
62
63
|
}
|
|
63
64
|
const follow = params.parsed.flags.get("follow") === true;
|
|
64
65
|
let afterId;
|
|
65
|
-
let stageRunId =
|
|
66
|
+
let stageRunId = parsePositiveIntegerFlag(params.parsed.flags.get("stage-run"), "--stage-run");
|
|
66
67
|
do {
|
|
67
68
|
const result = params.data.events(issueKey, {
|
|
68
69
|
...(stageRunId !== undefined ? { stageRunId } : {}),
|
|
@@ -102,7 +103,7 @@ export async function handleOpenCommand(params) {
|
|
|
102
103
|
throw new Error("open requires <issueKey>.");
|
|
103
104
|
}
|
|
104
105
|
if (params.json) {
|
|
105
|
-
const result = params.data.
|
|
106
|
+
const result = await params.data.resolveOpen(issueKey);
|
|
106
107
|
if (!result) {
|
|
107
108
|
throw new Error(`Workspace not found for ${issueKey}`);
|
|
108
109
|
}
|
|
@@ -110,11 +111,12 @@ export async function handleOpenCommand(params) {
|
|
|
110
111
|
return 0;
|
|
111
112
|
}
|
|
112
113
|
if (params.parsed.flags.get("print") === true) {
|
|
113
|
-
const result = params.data.
|
|
114
|
+
const result = await params.data.resolveOpen(issueKey);
|
|
114
115
|
if (!result) {
|
|
115
116
|
throw new Error(`Workspace not found for ${issueKey}`);
|
|
116
117
|
}
|
|
117
|
-
|
|
118
|
+
const openCommand = buildOpenCommand(params.config, result.workspace.worktreePath, result.resumeThreadId);
|
|
119
|
+
writeOutput(params.stdout, formatOpen(result, openCommand));
|
|
118
120
|
return 0;
|
|
119
121
|
}
|
|
120
122
|
const result = await params.data.prepareOpen(issueKey);
|
package/dist/cli/data.js
CHANGED
|
@@ -175,12 +175,14 @@ export class CliDataAccess {
|
|
|
175
175
|
...(resumeThreadId ? { resumeThreadId } : {}),
|
|
176
176
|
};
|
|
177
177
|
}
|
|
178
|
-
async
|
|
178
|
+
async resolveOpen(issueKey, options) {
|
|
179
179
|
const worktree = this.worktree(issueKey);
|
|
180
180
|
if (!worktree) {
|
|
181
181
|
return undefined;
|
|
182
182
|
}
|
|
183
|
-
|
|
183
|
+
if (options?.ensureWorktree) {
|
|
184
|
+
await this.ensureOpenWorktree(worktree);
|
|
185
|
+
}
|
|
184
186
|
const existingThreadId = await this.resolveStoredOpenThreadId(worktree);
|
|
185
187
|
if (existingThreadId) {
|
|
186
188
|
return {
|
|
@@ -188,6 +190,12 @@ export class CliDataAccess {
|
|
|
188
190
|
resumeThreadId: existingThreadId,
|
|
189
191
|
};
|
|
190
192
|
}
|
|
193
|
+
if (!options?.createThreadIfMissing) {
|
|
194
|
+
return {
|
|
195
|
+
...worktree,
|
|
196
|
+
needsNewSession: true,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
191
199
|
const codex = await this.getCodex();
|
|
192
200
|
const thread = await codex.startThread({
|
|
193
201
|
cwd: worktree.workspace.worktreePath,
|
|
@@ -206,6 +214,12 @@ export class CliDataAccess {
|
|
|
206
214
|
resumeThreadId: thread.id,
|
|
207
215
|
};
|
|
208
216
|
}
|
|
217
|
+
async prepareOpen(issueKey) {
|
|
218
|
+
return await this.resolveOpen(issueKey, {
|
|
219
|
+
ensureWorktree: true,
|
|
220
|
+
createThreadIfMissing: true,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
209
223
|
retry(issueKey, options) {
|
|
210
224
|
const issue = this.db.issueWorkflows.getTrackedIssueByKey(issueKey);
|
|
211
225
|
if (!issue) {
|
|
@@ -448,6 +462,72 @@ export class CliDataAccess {
|
|
|
448
462
|
async listInstallations() {
|
|
449
463
|
return await this.requestJson("/api/installations");
|
|
450
464
|
}
|
|
465
|
+
async listOperatorFeed(options) {
|
|
466
|
+
return await this.requestJson("/api/feed", {
|
|
467
|
+
...(options?.limit && options.limit > 0 ? { limit: String(options.limit) } : {}),
|
|
468
|
+
...(options?.issueKey ? { issue: options.issueKey } : {}),
|
|
469
|
+
...(options?.projectId ? { project: options.projectId } : {}),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
async followOperatorFeed(onEvent, options) {
|
|
473
|
+
const url = new URL("/api/feed", this.getOperatorBaseUrl());
|
|
474
|
+
url.searchParams.set("follow", "1");
|
|
475
|
+
if (options?.limit && options.limit > 0) {
|
|
476
|
+
url.searchParams.set("limit", String(options.limit));
|
|
477
|
+
}
|
|
478
|
+
if (options?.issueKey) {
|
|
479
|
+
url.searchParams.set("issue", options.issueKey);
|
|
480
|
+
}
|
|
481
|
+
if (options?.projectId) {
|
|
482
|
+
url.searchParams.set("project", options.projectId);
|
|
483
|
+
}
|
|
484
|
+
const response = await fetch(url, {
|
|
485
|
+
method: "GET",
|
|
486
|
+
headers: {
|
|
487
|
+
accept: "text/event-stream",
|
|
488
|
+
...(this.config.operatorApi.bearerToken ? { authorization: `Bearer ${this.config.operatorApi.bearerToken}` } : {}),
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
if (!response.ok || !response.body) {
|
|
492
|
+
const body = await response.text().catch(() => "");
|
|
493
|
+
const message = this.readErrorMessage(body);
|
|
494
|
+
throw new Error(message ?? `Request failed: ${response.status}`);
|
|
495
|
+
}
|
|
496
|
+
const reader = response.body.getReader();
|
|
497
|
+
const decoder = new TextDecoder();
|
|
498
|
+
let buffer = "";
|
|
499
|
+
let dataLines = [];
|
|
500
|
+
while (true) {
|
|
501
|
+
const { done, value } = await reader.read();
|
|
502
|
+
if (done) {
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
buffer += decoder.decode(value, { stream: true });
|
|
506
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
507
|
+
while (newlineIndex !== -1) {
|
|
508
|
+
const rawLine = buffer.slice(0, newlineIndex);
|
|
509
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
510
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
511
|
+
if (!line) {
|
|
512
|
+
if (dataLines.length > 0) {
|
|
513
|
+
const parsed = JSON.parse(dataLines.join("\n"));
|
|
514
|
+
onEvent(parsed);
|
|
515
|
+
dataLines = [];
|
|
516
|
+
}
|
|
517
|
+
newlineIndex = buffer.indexOf("\n");
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (line.startsWith(":")) {
|
|
521
|
+
newlineIndex = buffer.indexOf("\n");
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (line.startsWith("data:")) {
|
|
525
|
+
dataLines.push(line.slice(5).trimStart());
|
|
526
|
+
}
|
|
527
|
+
newlineIndex = buffer.indexOf("\n");
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
451
531
|
getOperatorBaseUrl() {
|
|
452
532
|
const host = this.normalizeLocalHost(this.config.server.bind);
|
|
453
533
|
return `http://${host}:${this.config.server.port}/`;
|
|
@@ -85,15 +85,22 @@ export function formatWorktree(result, cdOnly) {
|
|
|
85
85
|
value("Repo", result.repoId),
|
|
86
86
|
].join("\n")}\n`;
|
|
87
87
|
}
|
|
88
|
-
|
|
88
|
+
function formatCommand(command, args) {
|
|
89
|
+
return [command, ...args].join(" ");
|
|
90
|
+
}
|
|
91
|
+
export function formatOpen(result, command) {
|
|
89
92
|
const commands = [
|
|
90
93
|
`cd ${result.workspace.worktreePath}`,
|
|
91
94
|
"git branch --show-current",
|
|
92
|
-
"codex --dangerously-bypass-approvals-and-sandbox",
|
|
93
95
|
];
|
|
94
|
-
if (result.
|
|
95
|
-
commands.push(
|
|
96
|
+
if (result.needsNewSession) {
|
|
97
|
+
commands.push(`# No resumable thread found; \`patchrelay open ${result.issue.issueKey ?? result.issue.linearIssueId}\` will create a fresh session.`);
|
|
96
98
|
}
|
|
99
|
+
commands.push(command
|
|
100
|
+
? formatCommand(command.command, command.args)
|
|
101
|
+
: result.resumeThreadId
|
|
102
|
+
? `codex --dangerously-bypass-approvals-and-sandbox resume ${result.resumeThreadId}`
|
|
103
|
+
: "codex --dangerously-bypass-approvals-and-sandbox");
|
|
97
104
|
return `${commands.join("\n")}\n`;
|
|
98
105
|
}
|
|
99
106
|
export function formatRetry(result) {
|
|
@@ -117,3 +124,45 @@ export function formatList(items) {
|
|
|
117
124
|
].join("\t"))
|
|
118
125
|
.join("\n")}\n`;
|
|
119
126
|
}
|
|
127
|
+
function colorize(enabled, code, value) {
|
|
128
|
+
return enabled ? `\u001B[${code}m${value}\u001B[0m` : value;
|
|
129
|
+
}
|
|
130
|
+
function formatFeedStatus(event, color) {
|
|
131
|
+
const raw = event.status ?? event.kind;
|
|
132
|
+
const label = raw.replaceAll("_", " ");
|
|
133
|
+
const padded = label.padEnd(15);
|
|
134
|
+
if (event.level === "error" || raw === "failed" || raw === "delivery_failed") {
|
|
135
|
+
return colorize(color, "31", padded);
|
|
136
|
+
}
|
|
137
|
+
if (event.level === "warn" || raw === "ignored" || raw === "fallback" || raw === "handoff") {
|
|
138
|
+
return colorize(color, "33", padded);
|
|
139
|
+
}
|
|
140
|
+
if (raw === "running" || raw === "started" || raw === "delegated") {
|
|
141
|
+
return colorize(color, "32", padded);
|
|
142
|
+
}
|
|
143
|
+
if (raw === "queued") {
|
|
144
|
+
return colorize(color, "36", padded);
|
|
145
|
+
}
|
|
146
|
+
return colorize(color, "2", padded);
|
|
147
|
+
}
|
|
148
|
+
export function formatOperatorFeedEvent(event, options) {
|
|
149
|
+
const color = options?.color === true;
|
|
150
|
+
const timestamp = new Date(event.at).toLocaleTimeString("en-GB", { hour12: false });
|
|
151
|
+
const issue = event.issueKey ?? event.projectId ?? "-";
|
|
152
|
+
const line = [
|
|
153
|
+
colorize(color, "2", timestamp),
|
|
154
|
+
colorize(color, "1", issue.padEnd(10)),
|
|
155
|
+
formatFeedStatus(event, color),
|
|
156
|
+
event.summary,
|
|
157
|
+
].join(" ");
|
|
158
|
+
if (!event.detail) {
|
|
159
|
+
return `${line}\n`;
|
|
160
|
+
}
|
|
161
|
+
return `${line}\n${colorize(color, "2", ` ${truncateLine(event.detail)}`)}\n`;
|
|
162
|
+
}
|
|
163
|
+
export function formatOperatorFeed(result, options) {
|
|
164
|
+
if (result.events.length === 0) {
|
|
165
|
+
return "No feed events yet.\n";
|
|
166
|
+
}
|
|
167
|
+
return result.events.map((event) => formatOperatorFeedEvent(event, options)).join("");
|
|
168
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { loadConfig } from "../config.js";
|
|
2
|
+
import { getBuildInfo } from "../build-info.js";
|
|
2
3
|
import { runPreflight } from "../preflight.js";
|
|
3
|
-
import { parseArgs, resolveCommand } from "./args.js";
|
|
4
|
+
import { assertKnownFlags, parseArgs, resolveCommand } from "./args.js";
|
|
4
5
|
import { handleConnectCommand, handleInstallationsCommand } from "./commands/connect.js";
|
|
6
|
+
import { handleFeedCommand } from "./commands/feed.js";
|
|
5
7
|
import { handleEventsCommand, handleInspectCommand, handleListCommand, handleLiveCommand, handleOpenCommand, handleReportCommand, handleRetryCommand, handleWorktreeCommand, } from "./commands/issues.js";
|
|
6
8
|
import { handleProjectCommand } from "./commands/project.js";
|
|
7
9
|
import { handleInitCommand, handleInstallServiceCommand, handleRestartServiceCommand } from "./commands/setup.js";
|
|
@@ -38,8 +40,9 @@ function helpText() {
|
|
|
38
40
|
" upserts the repo config and reuses or starts the Linear connection flow.",
|
|
39
41
|
"",
|
|
40
42
|
"Commands:",
|
|
43
|
+
" version [--json] Show the installed PatchRelay build version",
|
|
41
44
|
" init <public-base-url> [--force] [--json] Bootstrap the machine-level PatchRelay home",
|
|
42
|
-
" project apply <id> <repo-path> [--issue-prefix <prefixes>] [--team-id <ids>] [--no-connect] [--timeout <seconds>] [--json]",
|
|
45
|
+
" project apply <id> <repo-path> [--issue-prefix <prefixes>] [--team-id <ids>] [--no-connect] [--no-open] [--timeout <seconds>] [--json]",
|
|
43
46
|
" Upsert one local repository and connect it to Linear when ready",
|
|
44
47
|
" doctor [--json] Check secrets, paths, configured workflow files, git, and codex",
|
|
45
48
|
" install-service [--force] [--write-only] [--json] Reinstall the systemd user service and watcher",
|
|
@@ -47,6 +50,8 @@ function helpText() {
|
|
|
47
50
|
" connect [--project <projectId>] [--no-open] [--timeout <seconds>] [--json]",
|
|
48
51
|
" Advanced: start or reuse a Linear installation directly",
|
|
49
52
|
" installations [--json] Show connected Linear installations",
|
|
53
|
+
" feed [--follow] [--limit <count>] [--issue <issueKey>] [--project <projectId>] [--json]",
|
|
54
|
+
" Show a live operator feed from the daemon",
|
|
50
55
|
" serve Run the local PatchRelay service",
|
|
51
56
|
" inspect <issueKey> Show the latest known issue state",
|
|
52
57
|
" live <issueKey> [--watch] [--json] Show the active run status",
|
|
@@ -64,11 +69,14 @@ function helpText() {
|
|
|
64
69
|
}
|
|
65
70
|
function getCommandConfigProfile(command) {
|
|
66
71
|
switch (command) {
|
|
72
|
+
case "version":
|
|
73
|
+
return "service";
|
|
67
74
|
case "doctor":
|
|
68
75
|
case "install-service":
|
|
69
76
|
return "doctor";
|
|
70
77
|
case "connect":
|
|
71
78
|
case "installations":
|
|
79
|
+
case "feed":
|
|
72
80
|
return "operator_cli";
|
|
73
81
|
case "inspect":
|
|
74
82
|
case "live":
|
|
@@ -83,20 +91,100 @@ function getCommandConfigProfile(command) {
|
|
|
83
91
|
return "service";
|
|
84
92
|
}
|
|
85
93
|
}
|
|
94
|
+
function validateFlags(command, commandArgs, parsed) {
|
|
95
|
+
switch (command) {
|
|
96
|
+
case "version":
|
|
97
|
+
assertKnownFlags(parsed, command, ["json"]);
|
|
98
|
+
return;
|
|
99
|
+
case "help":
|
|
100
|
+
case "serve":
|
|
101
|
+
assertKnownFlags(parsed, command, []);
|
|
102
|
+
return;
|
|
103
|
+
case "inspect":
|
|
104
|
+
assertKnownFlags(parsed, command, ["json"]);
|
|
105
|
+
return;
|
|
106
|
+
case "live":
|
|
107
|
+
assertKnownFlags(parsed, command, ["watch", "json"]);
|
|
108
|
+
return;
|
|
109
|
+
case "report":
|
|
110
|
+
assertKnownFlags(parsed, command, ["stage", "stage-run", "json"]);
|
|
111
|
+
return;
|
|
112
|
+
case "events":
|
|
113
|
+
assertKnownFlags(parsed, command, ["stage-run", "method", "follow", "json"]);
|
|
114
|
+
return;
|
|
115
|
+
case "worktree":
|
|
116
|
+
assertKnownFlags(parsed, command, ["cd", "json"]);
|
|
117
|
+
return;
|
|
118
|
+
case "open":
|
|
119
|
+
assertKnownFlags(parsed, command, ["print", "json"]);
|
|
120
|
+
return;
|
|
121
|
+
case "retry":
|
|
122
|
+
assertKnownFlags(parsed, command, ["stage", "reason", "json"]);
|
|
123
|
+
return;
|
|
124
|
+
case "list":
|
|
125
|
+
assertKnownFlags(parsed, command, ["active", "failed", "project", "json"]);
|
|
126
|
+
return;
|
|
127
|
+
case "doctor":
|
|
128
|
+
assertKnownFlags(parsed, command, ["json"]);
|
|
129
|
+
return;
|
|
130
|
+
case "init":
|
|
131
|
+
assertKnownFlags(parsed, command, ["force", "json", "public-base-url"]);
|
|
132
|
+
return;
|
|
133
|
+
case "project":
|
|
134
|
+
if (commandArgs[0] === "apply") {
|
|
135
|
+
assertKnownFlags(parsed, "project apply", ["issue-prefix", "team-id", "no-connect", "no-open", "timeout", "json"]);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
assertKnownFlags(parsed, command, []);
|
|
139
|
+
return;
|
|
140
|
+
case "connect":
|
|
141
|
+
assertKnownFlags(parsed, command, ["project", "no-open", "timeout", "json"]);
|
|
142
|
+
return;
|
|
143
|
+
case "installations":
|
|
144
|
+
assertKnownFlags(parsed, command, ["json"]);
|
|
145
|
+
return;
|
|
146
|
+
case "feed":
|
|
147
|
+
assertKnownFlags(parsed, command, ["follow", "limit", "issue", "project", "json"]);
|
|
148
|
+
return;
|
|
149
|
+
case "install-service":
|
|
150
|
+
assertKnownFlags(parsed, command, ["force", "write-only", "json"]);
|
|
151
|
+
return;
|
|
152
|
+
case "restart-service":
|
|
153
|
+
assertKnownFlags(parsed, command, ["json"]);
|
|
154
|
+
return;
|
|
155
|
+
default:
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
86
159
|
export async function runCli(argv, options) {
|
|
87
160
|
const stdout = options?.stdout ?? process.stdout;
|
|
88
161
|
const stderr = options?.stderr ?? process.stderr;
|
|
89
|
-
|
|
90
|
-
|
|
162
|
+
let parsed;
|
|
163
|
+
let command;
|
|
164
|
+
let commandArgs;
|
|
165
|
+
try {
|
|
166
|
+
parsed = parseArgs(argv);
|
|
167
|
+
({ command, commandArgs } = resolveCommand(parsed));
|
|
168
|
+
validateFlags(command, commandArgs, parsed);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
writeOutput(stderr, `${error instanceof Error ? error.message : String(error)}\n`);
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
const json = parsed.flags.get("json") === true;
|
|
91
175
|
if (command === "help") {
|
|
92
176
|
writeOutput(stdout, `${helpText()}\n`);
|
|
93
177
|
return 0;
|
|
94
178
|
}
|
|
179
|
+
if (command === "version") {
|
|
180
|
+
const buildInfo = getBuildInfo();
|
|
181
|
+
writeOutput(stdout, json ? formatJson(buildInfo) : `${buildInfo.version}\n`);
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
95
184
|
if (command === "serve") {
|
|
96
185
|
return -1;
|
|
97
186
|
}
|
|
98
187
|
const runInteractive = options?.runInteractive ?? runInteractiveCommand;
|
|
99
|
-
const json = parsed.flags.get("json") === true;
|
|
100
188
|
if (command === "init") {
|
|
101
189
|
return await handleInitCommand({
|
|
102
190
|
commandArgs,
|
|
@@ -185,6 +273,14 @@ export async function runCli(argv, options) {
|
|
|
185
273
|
data,
|
|
186
274
|
});
|
|
187
275
|
}
|
|
276
|
+
if (command === "feed") {
|
|
277
|
+
return await handleFeedCommand({
|
|
278
|
+
parsed,
|
|
279
|
+
json,
|
|
280
|
+
stdout,
|
|
281
|
+
data,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
188
284
|
if (command === "retry") {
|
|
189
285
|
return await handleRetryCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
|
|
190
286
|
}
|
package/dist/db/migrations.js
CHANGED
|
@@ -184,6 +184,19 @@ CREATE TABLE IF NOT EXISTS oauth_states (
|
|
|
184
184
|
error_message TEXT
|
|
185
185
|
);
|
|
186
186
|
|
|
187
|
+
CREATE TABLE IF NOT EXISTS operator_feed_events (
|
|
188
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
189
|
+
at TEXT NOT NULL,
|
|
190
|
+
level TEXT NOT NULL,
|
|
191
|
+
kind TEXT NOT NULL,
|
|
192
|
+
summary TEXT NOT NULL,
|
|
193
|
+
detail TEXT,
|
|
194
|
+
issue_key TEXT,
|
|
195
|
+
project_id TEXT,
|
|
196
|
+
stage TEXT,
|
|
197
|
+
status TEXT
|
|
198
|
+
);
|
|
199
|
+
|
|
187
200
|
CREATE INDEX IF NOT EXISTS idx_event_receipts_project_issue ON event_receipts(project_id, linear_issue_id);
|
|
188
201
|
CREATE INDEX IF NOT EXISTS idx_issue_control_ready ON issue_control(desired_stage, active_run_lease_id);
|
|
189
202
|
CREATE INDEX IF NOT EXISTS idx_issue_projection_issue_key ON issue_projection(issue_key);
|
|
@@ -192,6 +205,8 @@ CREATE INDEX IF NOT EXISTS idx_issue_sessions_last_opened ON issue_sessions(proj
|
|
|
192
205
|
CREATE INDEX IF NOT EXISTS idx_run_leases_active ON run_leases(status, project_id, linear_issue_id);
|
|
193
206
|
CREATE INDEX IF NOT EXISTS idx_run_leases_thread ON run_leases(thread_id);
|
|
194
207
|
CREATE INDEX IF NOT EXISTS idx_run_thread_events_run ON run_thread_events(run_lease_id, id);
|
|
208
|
+
CREATE INDEX IF NOT EXISTS idx_operator_feed_events_issue ON operator_feed_events(issue_key, id);
|
|
209
|
+
CREATE INDEX IF NOT EXISTS idx_operator_feed_events_project ON operator_feed_events(project_id, id);
|
|
195
210
|
CREATE INDEX IF NOT EXISTS idx_obligations_pending ON obligations(status, run_lease_id, kind);
|
|
196
211
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_obligations_dedupe
|
|
197
212
|
ON obligations(run_lease_id, kind, dedupe_key)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { isoNow } from "./shared.js";
|
|
2
|
+
export class OperatorFeedStore {
|
|
3
|
+
connection;
|
|
4
|
+
maxRows;
|
|
5
|
+
constructor(connection, maxRows = 5000) {
|
|
6
|
+
this.connection = connection;
|
|
7
|
+
this.maxRows = maxRows;
|
|
8
|
+
}
|
|
9
|
+
save(event) {
|
|
10
|
+
const at = event.at ?? isoNow();
|
|
11
|
+
const result = this.connection.prepare(`
|
|
12
|
+
INSERT INTO operator_feed_events (at, level, kind, summary, detail, issue_key, project_id, stage, status)
|
|
13
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
14
|
+
`).run(at, event.level, event.kind, event.summary, event.detail ?? null, event.issueKey ?? null, event.projectId ?? null, event.stage ?? null, event.status ?? null);
|
|
15
|
+
this.prune();
|
|
16
|
+
const stored = this.connection.prepare("SELECT * FROM operator_feed_events WHERE id = ?").get(Number(result.lastInsertRowid));
|
|
17
|
+
return mapOperatorFeedEvent(stored);
|
|
18
|
+
}
|
|
19
|
+
list(options) {
|
|
20
|
+
const clauses = [];
|
|
21
|
+
const params = [];
|
|
22
|
+
if (options?.afterId !== undefined) {
|
|
23
|
+
clauses.push("id > ?");
|
|
24
|
+
params.push(options.afterId);
|
|
25
|
+
}
|
|
26
|
+
if (options?.issueKey) {
|
|
27
|
+
clauses.push("issue_key = ?");
|
|
28
|
+
params.push(options.issueKey);
|
|
29
|
+
}
|
|
30
|
+
if (options?.projectId) {
|
|
31
|
+
clauses.push("project_id = ?");
|
|
32
|
+
params.push(options.projectId);
|
|
33
|
+
}
|
|
34
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
35
|
+
const limit = options?.limit ?? 50;
|
|
36
|
+
const rows = this.connection.prepare(`
|
|
37
|
+
SELECT *
|
|
38
|
+
FROM operator_feed_events
|
|
39
|
+
${where}
|
|
40
|
+
ORDER BY id DESC
|
|
41
|
+
LIMIT ?
|
|
42
|
+
`).all(...params, limit);
|
|
43
|
+
return rows
|
|
44
|
+
.map((row) => mapOperatorFeedEvent(row))
|
|
45
|
+
.reverse();
|
|
46
|
+
}
|
|
47
|
+
prune() {
|
|
48
|
+
this.connection.prepare(`
|
|
49
|
+
DELETE FROM operator_feed_events
|
|
50
|
+
WHERE id NOT IN (
|
|
51
|
+
SELECT id
|
|
52
|
+
FROM operator_feed_events
|
|
53
|
+
ORDER BY id DESC
|
|
54
|
+
LIMIT ?
|
|
55
|
+
)
|
|
56
|
+
`).run(this.maxRows);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function mapOperatorFeedEvent(row) {
|
|
60
|
+
return {
|
|
61
|
+
id: Number(row.id),
|
|
62
|
+
at: String(row.at),
|
|
63
|
+
level: row.level,
|
|
64
|
+
kind: row.kind,
|
|
65
|
+
summary: String(row.summary),
|
|
66
|
+
...(row.detail === null ? {} : { detail: String(row.detail) }),
|
|
67
|
+
...(row.issue_key === null ? {} : { issueKey: String(row.issue_key) }),
|
|
68
|
+
...(row.project_id === null ? {} : { projectId: String(row.project_id) }),
|
|
69
|
+
...(row.stage === null ? {} : { stage: row.stage }),
|
|
70
|
+
...(row.status === null ? {} : { status: String(row.status) }),
|
|
71
|
+
};
|
|
72
|
+
}
|
package/dist/db.js
CHANGED
|
@@ -4,6 +4,7 @@ import { IssueWorkflowCoordinator } from "./db/issue-workflow-coordinator.js";
|
|
|
4
4
|
import { IssueWorkflowStore } from "./db/issue-workflow-store.js";
|
|
5
5
|
import { LinearInstallationStore } from "./db/linear-installation-store.js";
|
|
6
6
|
import { runPatchRelayMigrations } from "./db/migrations.js";
|
|
7
|
+
import { OperatorFeedStore } from "./db/operator-feed-store.js";
|
|
7
8
|
import { RunReportStore } from "./db/run-report-store.js";
|
|
8
9
|
import { StageEventStore } from "./db/stage-event-store.js";
|
|
9
10
|
import { SqliteConnection } from "./db/shared.js";
|
|
@@ -24,6 +25,7 @@ export class PatchRelayDatabase {
|
|
|
24
25
|
runReports;
|
|
25
26
|
stageEvents;
|
|
26
27
|
linearInstallations;
|
|
28
|
+
operatorFeed;
|
|
27
29
|
constructor(databasePath, wal) {
|
|
28
30
|
this.connection = new SqliteConnection(databasePath);
|
|
29
31
|
this.connection.pragma("foreign_keys = ON");
|
|
@@ -54,6 +56,7 @@ export class PatchRelayDatabase {
|
|
|
54
56
|
});
|
|
55
57
|
this.stageEvents = new StageEventStore(this.connection);
|
|
56
58
|
this.linearInstallations = new LinearInstallationStore(this.connection);
|
|
59
|
+
this.operatorFeed = new OperatorFeedStore(this.connection);
|
|
57
60
|
}
|
|
58
61
|
runMigrations() {
|
|
59
62
|
runPatchRelayMigrations(this.connection);
|