patchrelay 0.82.0 → 0.83.1
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/db/migrations.js +43 -0
- package/dist/db/run-store.js +62 -4
- package/dist/db/schema-guard.js +2 -0
- package/dist/db/workflow-observation-store.js +61 -0
- package/dist/db/workflow-task-store.js +111 -0
- package/dist/db.js +75 -3
- package/dist/github-review-context.js +90 -0
- package/dist/github-webhook-handler.js +64 -11
- package/dist/idle-reconciliation.js +33 -6
- package/dist/issue-overview-query.js +2 -1
- package/dist/linear-issue-projection.js +37 -0
- package/dist/linear-progress-reporter.js +15 -2
- package/dist/orchestration-parent-wake.js +11 -0
- package/dist/run-context.js +6 -6
- package/dist/run-finalizer.js +102 -22
- package/dist/run-launcher.js +1 -0
- package/dist/run-notification-handler.js +16 -1
- package/dist/run-orchestrator.js +7 -0
- package/dist/run-wake-planner.js +120 -8
- package/dist/service-runtime.js +30 -2
- package/dist/service-startup-recovery.js +51 -61
- package/dist/service.js +3 -0
- package/dist/sqlite-errors.js +5 -0
- package/dist/tracked-issue-list-query.js +5 -1
- package/dist/wake-dispatcher.js +145 -21
- package/dist/webhook-handler.js +75 -30
- package/dist/webhooks/dependency-readiness-handler.js +19 -13
- package/dist/webhooks/desired-stage-recorder.js +3 -18
- package/dist/workflow-runtime.js +384 -0
- package/dist/workflow-task-reconciler.js +72 -0
- package/package.json +1 -1
- package/dist/github-webhook-reactive-run.js +0 -309
package/dist/build-info.json
CHANGED
package/dist/db/migrations.js
CHANGED
|
@@ -237,6 +237,36 @@ CREATE TABLE IF NOT EXISTS issue_children (
|
|
|
237
237
|
PRIMARY KEY (project_id, parent_linear_issue_id, child_linear_issue_id)
|
|
238
238
|
);
|
|
239
239
|
|
|
240
|
+
CREATE TABLE IF NOT EXISTS workflow_observations (
|
|
241
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
242
|
+
project_id TEXT NOT NULL,
|
|
243
|
+
subject_id TEXT NOT NULL,
|
|
244
|
+
source TEXT NOT NULL,
|
|
245
|
+
type TEXT NOT NULL,
|
|
246
|
+
payload_json TEXT,
|
|
247
|
+
dedupe_key TEXT,
|
|
248
|
+
observed_at TEXT NOT NULL
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
CREATE TABLE IF NOT EXISTS workflow_tasks (
|
|
252
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
253
|
+
project_id TEXT NOT NULL,
|
|
254
|
+
subject_id TEXT NOT NULL,
|
|
255
|
+
task_id TEXT NOT NULL,
|
|
256
|
+
task_type TEXT NOT NULL,
|
|
257
|
+
run_type TEXT,
|
|
258
|
+
status TEXT NOT NULL,
|
|
259
|
+
reason TEXT NOT NULL,
|
|
260
|
+
requirements_json TEXT,
|
|
261
|
+
authority_epoch INTEGER NOT NULL DEFAULT 0,
|
|
262
|
+
gate_action TEXT NOT NULL,
|
|
263
|
+
gate_reason TEXT,
|
|
264
|
+
created_at TEXT NOT NULL,
|
|
265
|
+
updated_at TEXT NOT NULL,
|
|
266
|
+
closed_at TEXT,
|
|
267
|
+
UNIQUE(project_id, subject_id, task_id)
|
|
268
|
+
);
|
|
269
|
+
|
|
240
270
|
CREATE INDEX IF NOT EXISTS idx_issues_project ON issues(project_id, linear_issue_id);
|
|
241
271
|
CREATE INDEX IF NOT EXISTS idx_issues_key ON issues(issue_key);
|
|
242
272
|
CREATE INDEX IF NOT EXISTS idx_issues_ready ON issues(pending_run_type, active_run_id);
|
|
@@ -260,6 +290,13 @@ CREATE INDEX IF NOT EXISTS idx_issue_dependencies_issue ON issue_dependencies(pr
|
|
|
260
290
|
CREATE INDEX IF NOT EXISTS idx_issue_dependencies_blocker ON issue_dependencies(project_id, blocker_linear_issue_id);
|
|
261
291
|
CREATE INDEX IF NOT EXISTS idx_issue_children_parent ON issue_children(project_id, parent_linear_issue_id);
|
|
262
292
|
CREATE INDEX IF NOT EXISTS idx_issue_children_child ON issue_children(project_id, child_linear_issue_id);
|
|
293
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_observations_subject ON workflow_observations(project_id, subject_id, id);
|
|
294
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_observations_recent ON workflow_observations(observed_at, id);
|
|
295
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_observations_dedupe
|
|
296
|
+
ON workflow_observations(project_id, subject_id, source, dedupe_key)
|
|
297
|
+
WHERE dedupe_key IS NOT NULL;
|
|
298
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_tasks_subject ON workflow_tasks(project_id, subject_id, status, id);
|
|
299
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_tasks_open ON workflow_tasks(status, project_id, updated_at);
|
|
263
300
|
`;
|
|
264
301
|
export function runPatchRelayMigrations(connection) {
|
|
265
302
|
connection.exec(schema);
|
|
@@ -309,6 +346,12 @@ export function runPatchRelayMigrations(connection) {
|
|
|
309
346
|
// Plan §4.4: hard publication-suppression flag for the
|
|
310
347
|
// mid-run-approval cancellation primitive.
|
|
311
348
|
addColumnIfMissing(connection, "runs", "should_not_publish", "INTEGER NOT NULL DEFAULT 0");
|
|
349
|
+
addColumnIfMissing(connection, "runs", "authority_epoch", "INTEGER NOT NULL DEFAULT 0");
|
|
350
|
+
addColumnIfMissing(connection, "runs", "lease_revoked_at", "TEXT");
|
|
351
|
+
addColumnIfMissing(connection, "runs", "lease_revoke_reason", "TEXT");
|
|
352
|
+
addColumnIfMissing(connection, "workflow_tasks", "authority_epoch", "INTEGER NOT NULL DEFAULT 0");
|
|
353
|
+
addColumnIfMissing(connection, "workflow_tasks", "gate_action", "TEXT NOT NULL DEFAULT 'wait'");
|
|
354
|
+
addColumnIfMissing(connection, "workflow_tasks", "gate_reason", "TEXT");
|
|
312
355
|
addColumnIfMissing(connection, "issues", "last_blocking_review_head_sha", "TEXT");
|
|
313
356
|
// Collapse awaiting_review into pr_open (state normalization)
|
|
314
357
|
connection.prepare("UPDATE issues SET factory_state = 'pr_open' WHERE factory_state = 'awaiting_review'").run();
|
package/dist/db/run-store.js
CHANGED
|
@@ -7,22 +7,50 @@ export class RunStore {
|
|
|
7
7
|
issues;
|
|
8
8
|
issueSessionProjection;
|
|
9
9
|
telemetry;
|
|
10
|
-
|
|
10
|
+
workflowObservations;
|
|
11
|
+
constructor(connection, mapRunRow, issues, issueSessionProjection, telemetry = noopTelemetry, workflowObservations) {
|
|
11
12
|
this.connection = connection;
|
|
12
13
|
this.mapRunRow = mapRunRow;
|
|
13
14
|
this.issues = issues;
|
|
14
15
|
this.issueSessionProjection = issueSessionProjection;
|
|
15
16
|
this.telemetry = telemetry;
|
|
17
|
+
this.workflowObservations = workflowObservations;
|
|
16
18
|
}
|
|
17
19
|
projectIssueRun(issue, options) {
|
|
18
20
|
this.issueSessionProjection.issueRunChanged(issue, options);
|
|
19
21
|
}
|
|
22
|
+
appendRunnerObservation(type, run, payload = {}) {
|
|
23
|
+
this.workflowObservations?.appendObservation({
|
|
24
|
+
projectId: run.projectId,
|
|
25
|
+
subjectId: run.linearIssueId,
|
|
26
|
+
source: "runner",
|
|
27
|
+
type,
|
|
28
|
+
payloadJson: JSON.stringify({
|
|
29
|
+
runId: run.id,
|
|
30
|
+
runType: run.runType,
|
|
31
|
+
status: run.status,
|
|
32
|
+
authorityEpoch: run.authorityEpoch,
|
|
33
|
+
...(run.launchPhase ? { launchPhase: run.launchPhase } : {}),
|
|
34
|
+
...(run.sourceHeadSha ? { sourceHeadSha: run.sourceHeadSha } : {}),
|
|
35
|
+
...(run.threadId ? { threadId: run.threadId } : {}),
|
|
36
|
+
...(run.turnId ? { turnId: run.turnId } : {}),
|
|
37
|
+
...(run.parentThreadId ? { parentThreadId: run.parentThreadId } : {}),
|
|
38
|
+
...(run.failureReason ? { failureReason: run.failureReason } : {}),
|
|
39
|
+
...(run.shouldNotPublish ? { shouldNotPublish: true } : {}),
|
|
40
|
+
...(run.leaseRevokedAt ? { leaseRevokedAt: run.leaseRevokedAt } : {}),
|
|
41
|
+
...(run.leaseRevokeReason ? { leaseRevokeReason: run.leaseRevokeReason } : {}),
|
|
42
|
+
...(run.endedAt ? { endedAt: run.endedAt } : {}),
|
|
43
|
+
...payload,
|
|
44
|
+
}),
|
|
45
|
+
dedupeKey: `runner:${type}:${run.id}`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
20
48
|
createRun(params) {
|
|
21
49
|
const now = isoNow();
|
|
22
50
|
const result = this.connection.prepare(`
|
|
23
|
-
INSERT INTO runs (issue_id, project_id, linear_issue_id, run_type, status, launch_phase, source_head_sha, prompt_text, started_at)
|
|
24
|
-
VALUES (?, ?, ?, ?, 'queued', 'claimed', ?, ?, ?)
|
|
25
|
-
`).run(params.issueId, params.projectId, params.linearIssueId, params.runType, params.sourceHeadSha ?? null, params.promptText ?? null, now);
|
|
51
|
+
INSERT INTO runs (issue_id, project_id, linear_issue_id, run_type, status, launch_phase, source_head_sha, prompt_text, authority_epoch, started_at)
|
|
52
|
+
VALUES (?, ?, ?, ?, 'queued', 'claimed', ?, ?, ?, ?)
|
|
53
|
+
`).run(params.issueId, params.projectId, params.linearIssueId, params.runType, params.sourceHeadSha ?? null, params.promptText ?? null, params.authorityEpoch ?? 0, now);
|
|
26
54
|
const run = this.getRunById(Number(result.lastInsertRowid));
|
|
27
55
|
const issue = this.issues.getIssue(params.projectId, params.linearIssueId);
|
|
28
56
|
if (issue) {
|
|
@@ -36,6 +64,7 @@ export class RunStore {
|
|
|
36
64
|
runType: run.runType,
|
|
37
65
|
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
38
66
|
});
|
|
67
|
+
this.appendRunnerObservation("runner.run_claimed", run);
|
|
39
68
|
return run;
|
|
40
69
|
}
|
|
41
70
|
getRunById(id) {
|
|
@@ -97,6 +126,7 @@ export class RunStore {
|
|
|
97
126
|
runType: run.runType,
|
|
98
127
|
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
99
128
|
});
|
|
129
|
+
this.appendRunnerObservation("runner.run_started", run);
|
|
100
130
|
}
|
|
101
131
|
updateRunTurnId(runId, turnId) {
|
|
102
132
|
this.connection.prepare("UPDATE runs SET turn_id = ? WHERE id = ?").run(turnId, runId);
|
|
@@ -147,6 +177,7 @@ export class RunStore {
|
|
|
147
177
|
runType: run.runType,
|
|
148
178
|
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
149
179
|
});
|
|
180
|
+
this.appendRunnerObservation("runner.run_finished", run);
|
|
150
181
|
}
|
|
151
182
|
// Plan §4.4: flag a still-running run as superseded. We deliberately
|
|
152
183
|
// do NOT change `status` here — the Codex turn must finish naturally
|
|
@@ -175,6 +206,33 @@ export class RunStore {
|
|
|
175
206
|
lastRunType: run.runType,
|
|
176
207
|
});
|
|
177
208
|
}
|
|
209
|
+
this.appendRunnerObservation("runner.run_superseded_requested", run, {
|
|
210
|
+
reason: params.reason,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
revokeRunLease(runId, params) {
|
|
214
|
+
this.connection.prepare(`
|
|
215
|
+
UPDATE runs SET
|
|
216
|
+
should_not_publish = 1,
|
|
217
|
+
lease_revoked_at = COALESCE(lease_revoked_at, ?),
|
|
218
|
+
lease_revoke_reason = COALESCE(lease_revoke_reason, ?),
|
|
219
|
+
failure_reason = COALESCE(failure_reason, ?)
|
|
220
|
+
WHERE id = ?
|
|
221
|
+
AND status IN ('queued', 'running')
|
|
222
|
+
`).run(params.revokedAt ?? isoNow(), params.reason, params.reason, runId);
|
|
223
|
+
const run = this.getRunById(runId);
|
|
224
|
+
if (!run)
|
|
225
|
+
return;
|
|
226
|
+
const issue = this.issues.getIssue(run.projectId, run.linearIssueId);
|
|
227
|
+
if (issue) {
|
|
228
|
+
this.projectIssueRun(issue, {
|
|
229
|
+
summaryText: params.reason,
|
|
230
|
+
lastRunType: run.runType,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
this.appendRunnerObservation("runner.run_revoked", run, {
|
|
234
|
+
reason: params.reason,
|
|
235
|
+
});
|
|
178
236
|
}
|
|
179
237
|
saveCompletionCheck(runId, params) {
|
|
180
238
|
this.connection.prepare(`
|
package/dist/db/schema-guard.js
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { isoNow } from "./shared.js";
|
|
2
|
+
export class WorkflowObservationStore {
|
|
3
|
+
connection;
|
|
4
|
+
mapWorkflowObservationRow;
|
|
5
|
+
constructor(connection, mapWorkflowObservationRow) {
|
|
6
|
+
this.connection = connection;
|
|
7
|
+
this.mapWorkflowObservationRow = mapWorkflowObservationRow;
|
|
8
|
+
}
|
|
9
|
+
appendObservation(params) {
|
|
10
|
+
if (params.dedupeKey) {
|
|
11
|
+
const existing = this.connection.prepare(`
|
|
12
|
+
SELECT * FROM workflow_observations
|
|
13
|
+
WHERE project_id = ? AND subject_id = ? AND source = ? AND dedupe_key = ?
|
|
14
|
+
ORDER BY id DESC LIMIT 1
|
|
15
|
+
`).get(params.projectId, params.subjectId, params.source, params.dedupeKey);
|
|
16
|
+
if (existing) {
|
|
17
|
+
return this.mapWorkflowObservationRow(existing);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const result = this.connection.prepare(`
|
|
21
|
+
INSERT INTO workflow_observations (
|
|
22
|
+
project_id, subject_id, source, type, payload_json, dedupe_key, observed_at
|
|
23
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
24
|
+
`).run(params.projectId, params.subjectId, params.source, params.type, params.payloadJson ?? null, params.dedupeKey ?? null, params.observedAt ?? isoNow());
|
|
25
|
+
return this.getObservation(Number(result.lastInsertRowid));
|
|
26
|
+
}
|
|
27
|
+
getObservation(id) {
|
|
28
|
+
const row = this.connection.prepare("SELECT * FROM workflow_observations WHERE id = ?")
|
|
29
|
+
.get(id);
|
|
30
|
+
return row ? this.mapWorkflowObservationRow(row) : undefined;
|
|
31
|
+
}
|
|
32
|
+
listObservations(projectId, subjectId) {
|
|
33
|
+
const rows = this.connection.prepare(`
|
|
34
|
+
SELECT * FROM workflow_observations
|
|
35
|
+
WHERE project_id = ? AND subject_id = ?
|
|
36
|
+
ORDER BY id
|
|
37
|
+
`).all(projectId, subjectId);
|
|
38
|
+
return rows.map(this.mapWorkflowObservationRow);
|
|
39
|
+
}
|
|
40
|
+
listRecentObservations(params = {}) {
|
|
41
|
+
const conditions = [];
|
|
42
|
+
const values = [];
|
|
43
|
+
if (params.projectId) {
|
|
44
|
+
conditions.push("project_id = ?");
|
|
45
|
+
values.push(params.projectId);
|
|
46
|
+
}
|
|
47
|
+
if (params.observedAfter) {
|
|
48
|
+
conditions.push("observed_at >= ?");
|
|
49
|
+
values.push(params.observedAfter);
|
|
50
|
+
}
|
|
51
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
52
|
+
const limit = params.limit ?? 250;
|
|
53
|
+
const rows = this.connection.prepare(`
|
|
54
|
+
SELECT * FROM workflow_observations
|
|
55
|
+
${where}
|
|
56
|
+
ORDER BY id DESC
|
|
57
|
+
LIMIT ?
|
|
58
|
+
`).all(...values, limit);
|
|
59
|
+
return rows.map(this.mapWorkflowObservationRow);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { isoNow } from "./shared.js";
|
|
2
|
+
export class WorkflowTaskStore {
|
|
3
|
+
connection;
|
|
4
|
+
mapWorkflowTaskRow;
|
|
5
|
+
constructor(connection, mapWorkflowTaskRow) {
|
|
6
|
+
this.connection = connection;
|
|
7
|
+
this.mapWorkflowTaskRow = mapWorkflowTaskRow;
|
|
8
|
+
}
|
|
9
|
+
reconcileTasks(params) {
|
|
10
|
+
const reconciledAt = params.reconciledAt ?? isoNow();
|
|
11
|
+
return this.connection.transaction(() => {
|
|
12
|
+
const previousOpen = this.listOpenTasks(params.projectId, params.subjectId);
|
|
13
|
+
const currentTaskIds = new Set(params.tasks.map((entry) => entry.task.id));
|
|
14
|
+
const opened = [];
|
|
15
|
+
const updated = [];
|
|
16
|
+
for (const entry of params.tasks) {
|
|
17
|
+
const existing = this.getTask(params.projectId, params.subjectId, entry.task.id);
|
|
18
|
+
const requirementsJson = entry.task.requirements ? JSON.stringify(entry.task.requirements) : undefined;
|
|
19
|
+
this.connection.prepare(`
|
|
20
|
+
INSERT INTO workflow_tasks (
|
|
21
|
+
project_id, subject_id, task_id, task_type, run_type, status, reason,
|
|
22
|
+
requirements_json, authority_epoch, gate_action, gate_reason,
|
|
23
|
+
created_at, updated_at, closed_at
|
|
24
|
+
) VALUES (?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, NULL)
|
|
25
|
+
ON CONFLICT(project_id, subject_id, task_id) DO UPDATE SET
|
|
26
|
+
task_type = excluded.task_type,
|
|
27
|
+
run_type = excluded.run_type,
|
|
28
|
+
status = 'open',
|
|
29
|
+
reason = excluded.reason,
|
|
30
|
+
requirements_json = excluded.requirements_json,
|
|
31
|
+
authority_epoch = excluded.authority_epoch,
|
|
32
|
+
gate_action = excluded.gate_action,
|
|
33
|
+
gate_reason = excluded.gate_reason,
|
|
34
|
+
updated_at = excluded.updated_at,
|
|
35
|
+
closed_at = NULL
|
|
36
|
+
`).run(params.projectId, params.subjectId, entry.task.id, entry.task.type, entry.task.runType ?? null, entry.task.reason, requirementsJson ?? null, entry.authorityEpoch, entry.gateAction, entry.gateReason ?? null, existing?.createdAt ?? reconciledAt, reconciledAt);
|
|
37
|
+
const saved = this.getTask(params.projectId, params.subjectId, entry.task.id);
|
|
38
|
+
if (!existing || existing.status === "closed") {
|
|
39
|
+
opened.push(saved);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
updated.push(saved);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const closed = [];
|
|
46
|
+
for (const stale of previousOpen) {
|
|
47
|
+
if (currentTaskIds.has(stale.taskId))
|
|
48
|
+
continue;
|
|
49
|
+
this.connection.prepare(`
|
|
50
|
+
UPDATE workflow_tasks
|
|
51
|
+
SET status = 'closed',
|
|
52
|
+
updated_at = ?,
|
|
53
|
+
closed_at = ?
|
|
54
|
+
WHERE id = ?
|
|
55
|
+
AND status = 'open'
|
|
56
|
+
`).run(reconciledAt, reconciledAt, stale.id);
|
|
57
|
+
const closedTask = this.getTaskById(stale.id);
|
|
58
|
+
if (closedTask) {
|
|
59
|
+
closed.push(closedTask);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
opened,
|
|
64
|
+
updated,
|
|
65
|
+
closed,
|
|
66
|
+
open: this.listOpenTasks(params.projectId, params.subjectId),
|
|
67
|
+
};
|
|
68
|
+
})();
|
|
69
|
+
}
|
|
70
|
+
getTask(projectId, subjectId, taskId) {
|
|
71
|
+
const row = this.connection.prepare(`
|
|
72
|
+
SELECT * FROM workflow_tasks
|
|
73
|
+
WHERE project_id = ? AND subject_id = ? AND task_id = ?
|
|
74
|
+
`).get(projectId, subjectId, taskId);
|
|
75
|
+
return row ? this.mapWorkflowTaskRow(row) : undefined;
|
|
76
|
+
}
|
|
77
|
+
getTaskById(id) {
|
|
78
|
+
const row = this.connection.prepare("SELECT * FROM workflow_tasks WHERE id = ?").get(id);
|
|
79
|
+
return row ? this.mapWorkflowTaskRow(row) : undefined;
|
|
80
|
+
}
|
|
81
|
+
listTasks(projectId, subjectId) {
|
|
82
|
+
const rows = this.connection.prepare(`
|
|
83
|
+
SELECT * FROM workflow_tasks
|
|
84
|
+
WHERE project_id = ? AND subject_id = ?
|
|
85
|
+
ORDER BY id
|
|
86
|
+
`).all(projectId, subjectId);
|
|
87
|
+
return rows.map(this.mapWorkflowTaskRow);
|
|
88
|
+
}
|
|
89
|
+
listOpenTasks(projectId, subjectId) {
|
|
90
|
+
const rows = this.connection.prepare(`
|
|
91
|
+
SELECT * FROM workflow_tasks
|
|
92
|
+
WHERE project_id = ? AND subject_id = ? AND status = 'open'
|
|
93
|
+
ORDER BY id
|
|
94
|
+
`).all(projectId, subjectId);
|
|
95
|
+
return rows.map(this.mapWorkflowTaskRow);
|
|
96
|
+
}
|
|
97
|
+
listOpenRunnableTasks(projectId) {
|
|
98
|
+
const rows = projectId
|
|
99
|
+
? this.connection.prepare(`
|
|
100
|
+
SELECT * FROM workflow_tasks
|
|
101
|
+
WHERE status = 'open' AND task_type = 'run' AND gate_action = 'start' AND project_id = ?
|
|
102
|
+
ORDER BY updated_at, id
|
|
103
|
+
`).all(projectId)
|
|
104
|
+
: this.connection.prepare(`
|
|
105
|
+
SELECT * FROM workflow_tasks
|
|
106
|
+
WHERE status = 'open' AND task_type = 'run' AND gate_action = 'start'
|
|
107
|
+
ORDER BY updated_at, id
|
|
108
|
+
`).all();
|
|
109
|
+
return rows.map(this.mapWorkflowTaskRow);
|
|
110
|
+
}
|
|
111
|
+
}
|
package/dist/db.js
CHANGED
|
@@ -6,6 +6,8 @@ import { OperatorFeedStore } from "./db/operator-feed-store.js";
|
|
|
6
6
|
import { RepositoryLinkStore } from "./db/repository-link-store.js";
|
|
7
7
|
import { RunStore } from "./db/run-store.js";
|
|
8
8
|
import { WebhookEventStore } from "./db/webhook-event-store.js";
|
|
9
|
+
import { WorkflowObservationStore } from "./db/workflow-observation-store.js";
|
|
10
|
+
import { WorkflowTaskStore } from "./db/workflow-task-store.js";
|
|
9
11
|
import { runPatchRelayMigrations } from "./db/migrations.js";
|
|
10
12
|
import { assertPatchRelaySchemaReady } from "./db/schema-guard.js";
|
|
11
13
|
import { SqliteConnection } from "./db/shared.js";
|
|
@@ -14,6 +16,7 @@ import { syncIssueSessionFromIssue } from "./issue-session-projector.js";
|
|
|
14
16
|
import { noopTelemetry } from "./telemetry.js";
|
|
15
17
|
import { TrackedIssueQuery } from "./tracked-issue-query.js";
|
|
16
18
|
import { WorkflowWakeResolver } from "./workflow-wake-resolver.js";
|
|
19
|
+
import { reconcileWorkflowTasksForIssue } from "./workflow-task-reconciler.js";
|
|
17
20
|
export class PatchRelayDatabase {
|
|
18
21
|
connection;
|
|
19
22
|
issueSessionProjection;
|
|
@@ -25,6 +28,8 @@ export class PatchRelayDatabase {
|
|
|
25
28
|
operatorFeed;
|
|
26
29
|
repositories;
|
|
27
30
|
webhookEvents;
|
|
31
|
+
workflowObservations;
|
|
32
|
+
workflowTasks;
|
|
28
33
|
issues;
|
|
29
34
|
issueSessions;
|
|
30
35
|
workflowWakes;
|
|
@@ -45,6 +50,8 @@ export class PatchRelayDatabase {
|
|
|
45
50
|
this.operatorFeed = new OperatorFeedStore(this.connection);
|
|
46
51
|
this.repositories = new RepositoryLinkStore(this.connection);
|
|
47
52
|
this.webhookEvents = new WebhookEventStore(this.connection);
|
|
53
|
+
this.workflowObservations = new WorkflowObservationStore(this.connection, mapWorkflowObservationRow);
|
|
54
|
+
this.workflowTasks = new WorkflowTaskStore(this.connection, mapWorkflowTaskRow);
|
|
48
55
|
this.issueSessionProjection = new ImmediateIssueSessionProjectionInvalidator({
|
|
49
56
|
getIssue: (projectId, linearIssueId) => this.issues.getIssue(projectId, linearIssueId),
|
|
50
57
|
listDependents: (projectId, blockerLinearIssueId) => this.issues.listDependents(projectId, blockerLinearIssueId),
|
|
@@ -61,10 +68,13 @@ export class PatchRelayDatabase {
|
|
|
61
68
|
telemetry: this.telemetryProxy,
|
|
62
69
|
});
|
|
63
70
|
this.issues = new IssueStore(this.connection, this.issueSessionProjection);
|
|
64
|
-
this.runs = new RunStore(this.connection, mapRunRow, this.issues, this.issueSessionProjection, this.telemetryProxy);
|
|
71
|
+
this.runs = new RunStore(this.connection, mapRunRow, this.issues, this.issueSessionProjection, this.telemetryProxy, this.workflowObservations);
|
|
65
72
|
this.issueSessions = new IssueSessionStore(this.connection, mapIssueSessionRow, mapIssueSessionEventRow, this.issues, this.runs, this.issueSessionProjection, this.telemetryProxy);
|
|
66
73
|
this.workflowWakes = new WorkflowWakeResolver(this.issues, this.issueSessions);
|
|
67
|
-
this.trackedIssues = new TrackedIssueQuery(this.issues, this.issueSessions,
|
|
74
|
+
this.trackedIssues = new TrackedIssueQuery(this.issues, this.issueSessions, {
|
|
75
|
+
hasPendingWake: (projectId, linearIssueId) => this.workflowWakes.hasPendingWake(projectId, linearIssueId)
|
|
76
|
+
|| this.workflowTasks.listOpenRunnableTasks(projectId).some((task) => task.subjectId === linearIssueId),
|
|
77
|
+
}, this.runs);
|
|
68
78
|
}
|
|
69
79
|
databasePath;
|
|
70
80
|
setTelemetry(telemetry) {
|
|
@@ -77,6 +87,21 @@ export class PatchRelayDatabase {
|
|
|
77
87
|
assertSchemaReady() {
|
|
78
88
|
assertPatchRelaySchemaReady(this.connection, this.databasePath);
|
|
79
89
|
}
|
|
90
|
+
describeSchema() {
|
|
91
|
+
const tableRows = this.connection.prepare(`
|
|
92
|
+
SELECT name FROM sqlite_master
|
|
93
|
+
WHERE type = 'table' AND name IN ('issues', 'issue_sessions', 'runs')
|
|
94
|
+
ORDER BY name
|
|
95
|
+
`).all();
|
|
96
|
+
const issueColumns = tableRows.some((row) => row.name === "issues")
|
|
97
|
+
? this.connection.prepare("PRAGMA table_info(issues)").all().map((row) => row.name)
|
|
98
|
+
: [];
|
|
99
|
+
return {
|
|
100
|
+
databasePath: this.databasePath,
|
|
101
|
+
tables: tableRows.map((row) => row.name),
|
|
102
|
+
issuesVersionColumnPresent: issueColumns.includes("version"),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
80
105
|
transaction(fn) {
|
|
81
106
|
return this.connection.transaction(fn)();
|
|
82
107
|
}
|
|
@@ -148,7 +173,20 @@ export class PatchRelayDatabase {
|
|
|
148
173
|
return this.issues.countUnresolvedBlockers(projectId, linearIssueId);
|
|
149
174
|
}
|
|
150
175
|
listIssuesReadyForExecution() {
|
|
151
|
-
|
|
176
|
+
const ready = new Map();
|
|
177
|
+
for (const issue of this.issues.listIssues()) {
|
|
178
|
+
reconcileWorkflowTasksForIssue(this, issue);
|
|
179
|
+
}
|
|
180
|
+
for (const issue of this.trackedIssues.listIssuesReadyForExecution()) {
|
|
181
|
+
ready.set(`${issue.projectId}:${issue.linearIssueId}`, issue);
|
|
182
|
+
}
|
|
183
|
+
for (const task of this.workflowTasks.listOpenRunnableTasks()) {
|
|
184
|
+
ready.set(`${task.projectId}:${task.subjectId}`, {
|
|
185
|
+
projectId: task.projectId,
|
|
186
|
+
linearIssueId: task.subjectId,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return [...ready.values()];
|
|
152
190
|
}
|
|
153
191
|
/**
|
|
154
192
|
* Issues idle in pr_open with no active run — candidates for state
|
|
@@ -274,7 +312,41 @@ function mapRunRow(row) {
|
|
|
274
312
|
...(row.report_json !== null ? { reportJson: String(row.report_json) } : {}),
|
|
275
313
|
...(row.failure_reason !== null ? { failureReason: String(row.failure_reason) } : {}),
|
|
276
314
|
...(row.should_not_publish === 1 || row.should_not_publish === true ? { shouldNotPublish: true } : {}),
|
|
315
|
+
authorityEpoch: Number(row.authority_epoch ?? 0),
|
|
316
|
+
...(row.lease_revoked_at !== null ? { leaseRevokedAt: String(row.lease_revoked_at) } : {}),
|
|
317
|
+
...(row.lease_revoke_reason !== null ? { leaseRevokeReason: String(row.lease_revoke_reason) } : {}),
|
|
277
318
|
startedAt: String(row.started_at),
|
|
278
319
|
...(row.ended_at !== null ? { endedAt: String(row.ended_at) } : {}),
|
|
279
320
|
};
|
|
280
321
|
}
|
|
322
|
+
function mapWorkflowObservationRow(row) {
|
|
323
|
+
return {
|
|
324
|
+
id: Number(row.id),
|
|
325
|
+
projectId: String(row.project_id),
|
|
326
|
+
subjectId: String(row.subject_id),
|
|
327
|
+
source: String(row.source),
|
|
328
|
+
type: String(row.type),
|
|
329
|
+
...(row.payload_json !== null && row.payload_json !== undefined ? { payloadJson: String(row.payload_json) } : {}),
|
|
330
|
+
...(row.dedupe_key !== null && row.dedupe_key !== undefined ? { dedupeKey: String(row.dedupe_key) } : {}),
|
|
331
|
+
observedAt: String(row.observed_at),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
function mapWorkflowTaskRow(row) {
|
|
335
|
+
return {
|
|
336
|
+
id: Number(row.id),
|
|
337
|
+
projectId: String(row.project_id),
|
|
338
|
+
subjectId: String(row.subject_id),
|
|
339
|
+
taskId: String(row.task_id),
|
|
340
|
+
taskType: String(row.task_type),
|
|
341
|
+
...(row.run_type !== null && row.run_type !== undefined ? { runType: String(row.run_type) } : {}),
|
|
342
|
+
status: String(row.status),
|
|
343
|
+
reason: String(row.reason),
|
|
344
|
+
...(row.requirements_json !== null && row.requirements_json !== undefined ? { requirementsJson: String(row.requirements_json) } : {}),
|
|
345
|
+
authorityEpoch: Number(row.authority_epoch ?? 0),
|
|
346
|
+
gateAction: String(row.gate_action),
|
|
347
|
+
...(row.gate_reason !== null && row.gate_reason !== undefined ? { gateReason: String(row.gate_reason) } : {}),
|
|
348
|
+
createdAt: String(row.created_at),
|
|
349
|
+
updatedAt: String(row.updated_at),
|
|
350
|
+
...(row.closed_at !== null && row.closed_at !== undefined ? { closedAt: String(row.closed_at) } : {}),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
|
|
2
|
+
export async function resolveGitHubRequestedChangesContext(params) {
|
|
3
|
+
const { linearIssueId, event, fetchImpl } = params;
|
|
4
|
+
const reviewComments = params.includeInlineComments === false
|
|
5
|
+
? undefined
|
|
6
|
+
: await fetchReviewCommentsForEvent(event, fetchImpl);
|
|
7
|
+
const identity = buildRequestedChangesWakeIdentity({
|
|
8
|
+
linearIssueId,
|
|
9
|
+
headSha: event.headSha,
|
|
10
|
+
reviewCommitId: event.reviewCommitId,
|
|
11
|
+
reviewId: event.reviewId,
|
|
12
|
+
reviewerName: event.reviewerName,
|
|
13
|
+
});
|
|
14
|
+
return {
|
|
15
|
+
dedupeKey: identity.dedupeKey,
|
|
16
|
+
context: {
|
|
17
|
+
requestedChangesCoalesceKey: identity.coalesceKey,
|
|
18
|
+
...(identity.headSha ? { requestedChangesHeadSha: identity.headSha } : {}),
|
|
19
|
+
reviewBody: event.reviewBody,
|
|
20
|
+
reviewCommitId: event.reviewCommitId,
|
|
21
|
+
reviewId: event.reviewId,
|
|
22
|
+
reviewUrl: buildGitHubReviewUrl(event.repoFullName, event.prNumber, event.reviewId),
|
|
23
|
+
reviewerName: event.reviewerName,
|
|
24
|
+
...(reviewComments && reviewComments.length > 0 ? { reviewComments } : {}),
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
async function fetchReviewCommentsForEvent(event, fetchImpl) {
|
|
29
|
+
if (event.triggerEvent !== "review_changes_requested") {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
if (!event.repoFullName || event.prNumber === undefined || event.reviewId === undefined) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
36
|
+
if (!token) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const [owner, repo] = event.repoFullName.split("/", 2);
|
|
40
|
+
if (!owner || !repo) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const response = await fetchImpl(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${event.prNumber}/reviews/${event.reviewId}/comments?per_page=100`, {
|
|
44
|
+
headers: {
|
|
45
|
+
Authorization: `Bearer ${token}`,
|
|
46
|
+
Accept: "application/vnd.github+json",
|
|
47
|
+
"User-Agent": "patchrelay",
|
|
48
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
throw new Error(`GitHub review comment fetch failed (${response.status})`);
|
|
53
|
+
}
|
|
54
|
+
const payload = await response.json();
|
|
55
|
+
if (!Array.isArray(payload)) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const comments = [];
|
|
59
|
+
for (const entry of payload) {
|
|
60
|
+
if (!entry || typeof entry !== "object")
|
|
61
|
+
continue;
|
|
62
|
+
const record = entry;
|
|
63
|
+
const body = typeof record.body === "string" ? record.body.trim() : "";
|
|
64
|
+
const id = typeof record.id === "number" ? record.id : undefined;
|
|
65
|
+
if (!body || id === undefined)
|
|
66
|
+
continue;
|
|
67
|
+
comments.push({
|
|
68
|
+
id,
|
|
69
|
+
body,
|
|
70
|
+
...(typeof record.path === "string" ? { path: record.path } : {}),
|
|
71
|
+
...(typeof record.line === "number" ? { line: record.line } : {}),
|
|
72
|
+
...(typeof record.side === "string" ? { side: record.side } : {}),
|
|
73
|
+
...(typeof record.start_line === "number" ? { startLine: record.start_line } : {}),
|
|
74
|
+
...(typeof record.start_side === "string" ? { startSide: record.start_side } : {}),
|
|
75
|
+
...(typeof record.commit_id === "string" ? { commitId: record.commit_id } : {}),
|
|
76
|
+
...(typeof record.html_url === "string" ? { url: record.html_url } : {}),
|
|
77
|
+
...(typeof record.diff_hunk === "string" ? { diffHunk: record.diff_hunk } : {}),
|
|
78
|
+
...(typeof record.user?.login === "string"
|
|
79
|
+
? { authorLogin: String(record.user.login) }
|
|
80
|
+
: {}),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return comments;
|
|
84
|
+
}
|
|
85
|
+
function buildGitHubReviewUrl(repoFullName, prNumber, reviewId) {
|
|
86
|
+
if (!repoFullName || prNumber === undefined || reviewId === undefined) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
return `https://github.com/${repoFullName}/pull/${prNumber}#pullrequestreview-${reviewId}`;
|
|
90
|
+
}
|