patchrelay 0.74.6 → 0.74.8
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/codex-status.js +2 -1
- package/dist/db/issue-session-store.js +46 -4
- package/dist/db/issue-store.js +9 -4
- package/dist/db/run-store.js +44 -8
- package/dist/db.js +31 -11
- package/dist/index.js +12 -0
- package/dist/issue-session-lease-service.js +57 -3
- package/dist/issue-session-projection-invalidator.js +90 -0
- package/dist/preflight.js +3 -0
- package/dist/run-orchestrator.js +66 -3
- package/dist/service.js +9 -3
- package/dist/telemetry.js +85 -0
- package/dist/wake-dispatcher.js +180 -6
- package/dist/webhook-handler.js +6 -3
- package/dist/webhooks/dependency-readiness-handler.js +57 -6
- package/dist/workflow-wake-resolver.js +5 -3
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/codex-status.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
const CODEX_STATUS_TIMEOUT_MS = 15_000;
|
|
3
3
|
function stripAnsiCodes(value) {
|
|
4
|
-
|
|
4
|
+
const escape = String.fromCharCode(27);
|
|
5
|
+
return value.replace(new RegExp(`${escape}\\[[0-9;]*m`, "g"), "");
|
|
5
6
|
}
|
|
6
7
|
function parseAccountLine(output) {
|
|
7
8
|
const lines = output.split(/\r?\n/);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { deriveSessionWakePlan, isActionableIssueSessionEventType } from "../issue-session-events.js";
|
|
2
2
|
import { mergeRequestedChangesEventJson, readRequestedChangesCoalesceKey } from "../reactive-wake-keys.js";
|
|
3
|
+
import { emitTelemetry, noopTelemetry } from "../telemetry.js";
|
|
3
4
|
import { isoNow } from "./shared.js";
|
|
4
5
|
export class IssueSessionStore {
|
|
5
6
|
connection;
|
|
@@ -7,12 +8,16 @@ export class IssueSessionStore {
|
|
|
7
8
|
mapIssueSessionEventRow;
|
|
8
9
|
issues;
|
|
9
10
|
runs;
|
|
10
|
-
|
|
11
|
+
issueSessionProjection;
|
|
12
|
+
telemetry;
|
|
13
|
+
constructor(connection, mapIssueSessionRow, mapIssueSessionEventRow, issues, runs, issueSessionProjection, telemetry = noopTelemetry) {
|
|
11
14
|
this.connection = connection;
|
|
12
15
|
this.mapIssueSessionRow = mapIssueSessionRow;
|
|
13
16
|
this.mapIssueSessionEventRow = mapIssueSessionEventRow;
|
|
14
17
|
this.issues = issues;
|
|
15
18
|
this.runs = runs;
|
|
19
|
+
this.issueSessionProjection = issueSessionProjection;
|
|
20
|
+
this.telemetry = telemetry;
|
|
16
21
|
}
|
|
17
22
|
getIssueSession(projectId, linearIssueId) {
|
|
18
23
|
const row = this.connection
|
|
@@ -31,19 +36,25 @@ export class IssueSessionStore {
|
|
|
31
36
|
WHERE project_id = ? AND linear_issue_id = ? AND dedupe_key = ? AND processed_at IS NULL
|
|
32
37
|
ORDER BY id DESC LIMIT 1
|
|
33
38
|
`).get(params.projectId, params.linearIssueId, params.dedupeKey);
|
|
34
|
-
if (existing)
|
|
39
|
+
if (existing) {
|
|
40
|
+
this.issueSessionProjection.issueSessionEventsChanged(params.projectId, params.linearIssueId);
|
|
35
41
|
return this.mapIssueSessionEventRow(existing);
|
|
42
|
+
}
|
|
36
43
|
}
|
|
37
44
|
const coalesced = this.coalescePendingRequestedChangesEvent(params);
|
|
38
|
-
if (coalesced)
|
|
45
|
+
if (coalesced) {
|
|
46
|
+
this.issueSessionProjection.issueSessionEventsChanged(params.projectId, params.linearIssueId);
|
|
39
47
|
return coalesced;
|
|
48
|
+
}
|
|
40
49
|
const now = isoNow();
|
|
41
50
|
const result = this.connection.prepare(`
|
|
42
51
|
INSERT INTO issue_session_events (
|
|
43
52
|
project_id, linear_issue_id, event_type, event_json, dedupe_key, created_at
|
|
44
53
|
) VALUES (?, ?, ?, ?, ?, ?)
|
|
45
54
|
`).run(params.projectId, params.linearIssueId, params.eventType, params.eventJson ?? null, params.dedupeKey ?? null, now);
|
|
46
|
-
|
|
55
|
+
const event = this.getIssueSessionEvent(Number(result.lastInsertRowid));
|
|
56
|
+
this.issueSessionProjection.issueSessionEventsChanged(params.projectId, params.linearIssueId);
|
|
57
|
+
return event;
|
|
47
58
|
}
|
|
48
59
|
coalescePendingRequestedChangesEvent(params) {
|
|
49
60
|
if (params.eventType !== "review_changes_requested")
|
|
@@ -104,6 +115,16 @@ export class IssueSessionStore {
|
|
|
104
115
|
SET processed_at = ?, consumed_by_run_id = ?
|
|
105
116
|
WHERE project_id = ? AND linear_issue_id = ? AND id IN (${placeholders}) AND processed_at IS NULL
|
|
106
117
|
`).run(now, runId, projectId, linearIssueId, ...eventIds);
|
|
118
|
+
this.issueSessionProjection.issueSessionEventsChanged(projectId, linearIssueId);
|
|
119
|
+
const issue = this.issues.getIssue(projectId, linearIssueId);
|
|
120
|
+
emitTelemetry(this.telemetry, {
|
|
121
|
+
type: "wake.consumed",
|
|
122
|
+
projectId,
|
|
123
|
+
linearIssueId,
|
|
124
|
+
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
125
|
+
eventIds,
|
|
126
|
+
runId,
|
|
127
|
+
});
|
|
107
128
|
}
|
|
108
129
|
dismissIssueSessionEvents(projectId, linearIssueId, eventIds) {
|
|
109
130
|
if (eventIds.length === 0)
|
|
@@ -114,13 +135,34 @@ export class IssueSessionStore {
|
|
|
114
135
|
SET processed_at = ?, consumed_by_run_id = NULL
|
|
115
136
|
WHERE project_id = ? AND linear_issue_id = ? AND id IN (${placeholders}) AND processed_at IS NULL
|
|
116
137
|
`).run(isoNow(), projectId, linearIssueId, ...eventIds);
|
|
138
|
+
this.issueSessionProjection.issueSessionEventsChanged(projectId, linearIssueId);
|
|
139
|
+
const issue = this.issues.getIssue(projectId, linearIssueId);
|
|
140
|
+
emitTelemetry(this.telemetry, {
|
|
141
|
+
type: "wake.dismissed",
|
|
142
|
+
projectId,
|
|
143
|
+
linearIssueId,
|
|
144
|
+
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
145
|
+
eventIds,
|
|
146
|
+
reason: "dismissed",
|
|
147
|
+
});
|
|
117
148
|
}
|
|
118
149
|
clearPendingIssueSessionEvents(projectId, linearIssueId) {
|
|
150
|
+
const eventIds = this.listIssueSessionEvents(projectId, linearIssueId, { pendingOnly: true }).map((event) => event.id);
|
|
119
151
|
this.connection.prepare(`
|
|
120
152
|
UPDATE issue_session_events
|
|
121
153
|
SET processed_at = ?, consumed_by_run_id = NULL
|
|
122
154
|
WHERE project_id = ? AND linear_issue_id = ? AND processed_at IS NULL
|
|
123
155
|
`).run(isoNow(), projectId, linearIssueId);
|
|
156
|
+
this.issueSessionProjection.issueSessionEventsChanged(projectId, linearIssueId);
|
|
157
|
+
const issue = this.issues.getIssue(projectId, linearIssueId);
|
|
158
|
+
emitTelemetry(this.telemetry, {
|
|
159
|
+
type: "wake.dismissed",
|
|
160
|
+
projectId,
|
|
161
|
+
linearIssueId,
|
|
162
|
+
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
163
|
+
eventIds,
|
|
164
|
+
reason: "cleared_pending",
|
|
165
|
+
});
|
|
124
166
|
}
|
|
125
167
|
hasPendingIssueSessionEvents(projectId, linearIssueId) {
|
|
126
168
|
return this.listIssueSessionEvents(projectId, linearIssueId, { pendingOnly: true })
|
package/dist/db/issue-store.js
CHANGED
|
@@ -10,10 +10,10 @@ const OPEN_CHILD_PREDICATE = `
|
|
|
10
10
|
`;
|
|
11
11
|
export class IssueStore {
|
|
12
12
|
connection;
|
|
13
|
-
|
|
14
|
-
constructor(connection,
|
|
13
|
+
issueSessionProjection;
|
|
14
|
+
constructor(connection, issueSessionProjection) {
|
|
15
15
|
this.connection = connection;
|
|
16
|
-
this.
|
|
16
|
+
this.issueSessionProjection = issueSessionProjection;
|
|
17
17
|
}
|
|
18
18
|
upsertIssue(params) {
|
|
19
19
|
const now = isoNow();
|
|
@@ -39,7 +39,7 @@ export class IssueStore {
|
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
41
|
const updated = this.getIssue(params.projectId, params.linearIssueId);
|
|
42
|
-
this.
|
|
42
|
+
this.issueSessionProjection.issueChanged(updated);
|
|
43
43
|
return updated;
|
|
44
44
|
}
|
|
45
45
|
getIssue(projectId, linearIssueId) {
|
|
@@ -163,6 +163,7 @@ export class IssueStore {
|
|
|
163
163
|
.prepare("DELETE FROM issue_dependencies WHERE project_id = ? AND linear_issue_id = ?")
|
|
164
164
|
.run(params.projectId, params.linearIssueId);
|
|
165
165
|
if (params.blockers.length === 0) {
|
|
166
|
+
this.issueSessionProjection.issueDependenciesChanged(params.projectId, params.linearIssueId);
|
|
166
167
|
return;
|
|
167
168
|
}
|
|
168
169
|
const insert = this.connection.prepare(`
|
|
@@ -180,6 +181,7 @@ export class IssueStore {
|
|
|
180
181
|
for (const blocker of params.blockers) {
|
|
181
182
|
insert.run(params.projectId, params.linearIssueId, blocker.blockerLinearIssueId, blocker.blockerIssueKey ?? null, blocker.blockerTitle ?? null, blocker.blockerCurrentLinearState ?? null, blocker.blockerCurrentLinearStateType ?? null, now);
|
|
182
183
|
}
|
|
184
|
+
this.issueSessionProjection.issueDependenciesChanged(params.projectId, params.linearIssueId);
|
|
183
185
|
}
|
|
184
186
|
updateDependencyBlockerSnapshot(params) {
|
|
185
187
|
const sets = ["updated_at = @now"];
|
|
@@ -210,6 +212,9 @@ export class IssueStore {
|
|
|
210
212
|
WHERE project_id = @projectId
|
|
211
213
|
AND blocker_linear_issue_id = @blockerLinearIssueId
|
|
212
214
|
`).run(values);
|
|
215
|
+
if (Number(result.changes) > 0) {
|
|
216
|
+
this.issueSessionProjection.dependencyBlockerChanged(params.projectId, params.blockerLinearIssueId);
|
|
217
|
+
}
|
|
213
218
|
return Number(result.changes);
|
|
214
219
|
}
|
|
215
220
|
listIssueDependencies(projectId, linearIssueId) {
|
package/dist/db/run-store.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import { extractLatestAssistantSummary } from "../issue-session-events.js";
|
|
2
|
+
import { emitTelemetry, noopTelemetry } from "../telemetry.js";
|
|
2
3
|
import { isoNow } from "./shared.js";
|
|
3
4
|
export class RunStore {
|
|
4
5
|
connection;
|
|
5
6
|
mapRunRow;
|
|
6
7
|
issues;
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
issueSessionProjection;
|
|
9
|
+
telemetry;
|
|
10
|
+
constructor(connection, mapRunRow, issues, issueSessionProjection, telemetry = noopTelemetry) {
|
|
9
11
|
this.connection = connection;
|
|
10
12
|
this.mapRunRow = mapRunRow;
|
|
11
13
|
this.issues = issues;
|
|
12
|
-
this.
|
|
14
|
+
this.issueSessionProjection = issueSessionProjection;
|
|
15
|
+
this.telemetry = telemetry;
|
|
16
|
+
}
|
|
17
|
+
projectIssueRun(issue, options) {
|
|
18
|
+
this.issueSessionProjection.issueRunChanged(issue, options);
|
|
13
19
|
}
|
|
14
20
|
createRun(params) {
|
|
15
21
|
const now = isoNow();
|
|
@@ -20,8 +26,16 @@ export class RunStore {
|
|
|
20
26
|
const run = this.getRunById(Number(result.lastInsertRowid));
|
|
21
27
|
const issue = this.issues.getIssue(params.projectId, params.linearIssueId);
|
|
22
28
|
if (issue) {
|
|
23
|
-
this.
|
|
29
|
+
this.projectIssueRun(issue, { lastRunType: run.runType });
|
|
24
30
|
}
|
|
31
|
+
emitTelemetry(this.telemetry, {
|
|
32
|
+
type: "run.claimed",
|
|
33
|
+
projectId: params.projectId,
|
|
34
|
+
linearIssueId: params.linearIssueId,
|
|
35
|
+
runId: run.id,
|
|
36
|
+
runType: run.runType,
|
|
37
|
+
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
38
|
+
});
|
|
25
39
|
return run;
|
|
26
40
|
}
|
|
27
41
|
getRunById(id) {
|
|
@@ -72,8 +86,16 @@ export class RunStore {
|
|
|
72
86
|
return;
|
|
73
87
|
const issue = this.issues.getIssue(run.projectId, run.linearIssueId);
|
|
74
88
|
if (issue) {
|
|
75
|
-
this.
|
|
89
|
+
this.projectIssueRun(issue);
|
|
76
90
|
}
|
|
91
|
+
emitTelemetry(this.telemetry, {
|
|
92
|
+
type: "run.started",
|
|
93
|
+
projectId: run.projectId,
|
|
94
|
+
linearIssueId: run.linearIssueId,
|
|
95
|
+
runId: run.id,
|
|
96
|
+
runType: run.runType,
|
|
97
|
+
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
98
|
+
});
|
|
77
99
|
}
|
|
78
100
|
updateRunTurnId(runId, turnId) {
|
|
79
101
|
this.connection.prepare("UPDATE runs SET turn_id = ? WHERE id = ?").run(turnId, runId);
|
|
@@ -96,11 +118,25 @@ export class RunStore {
|
|
|
96
118
|
return;
|
|
97
119
|
const issue = this.issues.getIssue(run.projectId, run.linearIssueId);
|
|
98
120
|
if (issue) {
|
|
99
|
-
this.
|
|
121
|
+
this.projectIssueRun(issue, {
|
|
100
122
|
summaryText: extractLatestAssistantSummary(this.getRunById(runId) ?? run),
|
|
101
123
|
lastRunType: run.runType,
|
|
102
124
|
});
|
|
103
125
|
}
|
|
126
|
+
emitTelemetry(this.telemetry, {
|
|
127
|
+
type: params.status === "completed"
|
|
128
|
+
? "run.completed"
|
|
129
|
+
: params.status === "released"
|
|
130
|
+
? "run.released"
|
|
131
|
+
: params.status === "superseded"
|
|
132
|
+
? "run.superseded"
|
|
133
|
+
: "run.failed",
|
|
134
|
+
projectId: run.projectId,
|
|
135
|
+
linearIssueId: run.linearIssueId,
|
|
136
|
+
runId: run.id,
|
|
137
|
+
runType: run.runType,
|
|
138
|
+
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
139
|
+
});
|
|
104
140
|
}
|
|
105
141
|
// Plan §4.4: flag a still-running run as superseded. We deliberately
|
|
106
142
|
// do NOT change `status` here — the Codex turn must finish naturally
|
|
@@ -124,7 +160,7 @@ export class RunStore {
|
|
|
124
160
|
return;
|
|
125
161
|
const issue = this.issues.getIssue(run.projectId, run.linearIssueId);
|
|
126
162
|
if (issue) {
|
|
127
|
-
this.
|
|
163
|
+
this.projectIssueRun(issue, {
|
|
128
164
|
summaryText: params.reason,
|
|
129
165
|
lastRunType: run.runType,
|
|
130
166
|
});
|
|
@@ -157,7 +193,7 @@ export class RunStore {
|
|
|
157
193
|
return;
|
|
158
194
|
const issue = this.issues.getIssue(run.projectId, run.linearIssueId);
|
|
159
195
|
if (issue) {
|
|
160
|
-
this.
|
|
196
|
+
this.projectIssueRun(issue, {
|
|
161
197
|
lastRunType: run.runType,
|
|
162
198
|
});
|
|
163
199
|
}
|
package/dist/db.js
CHANGED
|
@@ -9,11 +9,17 @@ import { WebhookEventStore } from "./db/webhook-event-store.js";
|
|
|
9
9
|
import { runPatchRelayMigrations } from "./db/migrations.js";
|
|
10
10
|
import { assertPatchRelaySchemaReady } from "./db/schema-guard.js";
|
|
11
11
|
import { SqliteConnection } from "./db/shared.js";
|
|
12
|
+
import { ImmediateIssueSessionProjectionInvalidator } from "./issue-session-projection-invalidator.js";
|
|
12
13
|
import { syncIssueSessionFromIssue } from "./issue-session-projector.js";
|
|
14
|
+
import { noopTelemetry } from "./telemetry.js";
|
|
13
15
|
import { TrackedIssueQuery } from "./tracked-issue-query.js";
|
|
14
16
|
import { WorkflowWakeResolver } from "./workflow-wake-resolver.js";
|
|
15
17
|
export class PatchRelayDatabase {
|
|
16
18
|
connection;
|
|
19
|
+
telemetry = noopTelemetry;
|
|
20
|
+
telemetryProxy = {
|
|
21
|
+
emit: (event) => this.telemetry.emit(event),
|
|
22
|
+
};
|
|
17
23
|
linearInstallations;
|
|
18
24
|
operatorFeed;
|
|
19
25
|
repositories;
|
|
@@ -23,8 +29,11 @@ export class PatchRelayDatabase {
|
|
|
23
29
|
workflowWakes;
|
|
24
30
|
runs;
|
|
25
31
|
trackedIssues;
|
|
26
|
-
constructor(databasePath, wal) {
|
|
32
|
+
constructor(databasePath, wal, telemetry) {
|
|
27
33
|
this.databasePath = databasePath;
|
|
34
|
+
if (telemetry) {
|
|
35
|
+
this.telemetry = telemetry;
|
|
36
|
+
}
|
|
28
37
|
this.connection = new SqliteConnection(databasePath);
|
|
29
38
|
this.connection.pragma("foreign_keys = ON");
|
|
30
39
|
if (wal) {
|
|
@@ -34,20 +43,31 @@ export class PatchRelayDatabase {
|
|
|
34
43
|
this.operatorFeed = new OperatorFeedStore(this.connection);
|
|
35
44
|
this.repositories = new RepositoryLinkStore(this.connection);
|
|
36
45
|
this.webhookEvents = new WebhookEventStore(this.connection);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
const issueSessionProjection = new ImmediateIssueSessionProjectionInvalidator({
|
|
47
|
+
getIssue: (projectId, linearIssueId) => this.issues.getIssue(projectId, linearIssueId),
|
|
48
|
+
listDependents: (projectId, blockerLinearIssueId) => this.issues.listDependents(projectId, blockerLinearIssueId),
|
|
49
|
+
countUnresolvedBlockers: (projectId, linearIssueId) => this.issues.countUnresolvedBlockers(projectId, linearIssueId),
|
|
50
|
+
getIssueSessionWaitingReason: (projectId, linearIssueId) => this.issueSessions.getIssueSession(projectId, linearIssueId)?.waitingReason,
|
|
51
|
+
projectIssue: (issue, options) => syncIssueSessionFromIssue({
|
|
52
|
+
connection: this.connection,
|
|
53
|
+
issues: this.issues,
|
|
54
|
+
issueSessions: this.issueSessions,
|
|
55
|
+
runs: this.runs,
|
|
56
|
+
issue,
|
|
57
|
+
...(options ? { options } : {}),
|
|
58
|
+
}),
|
|
59
|
+
telemetry: this.telemetryProxy,
|
|
60
|
+
});
|
|
61
|
+
this.issues = new IssueStore(this.connection, issueSessionProjection);
|
|
62
|
+
this.runs = new RunStore(this.connection, mapRunRow, this.issues, issueSessionProjection, this.telemetryProxy);
|
|
63
|
+
this.issueSessions = new IssueSessionStore(this.connection, mapIssueSessionRow, mapIssueSessionEventRow, this.issues, this.runs, issueSessionProjection, this.telemetryProxy);
|
|
47
64
|
this.workflowWakes = new WorkflowWakeResolver(this.issues, this.issueSessions);
|
|
48
65
|
this.trackedIssues = new TrackedIssueQuery(this.issues, this.issueSessions, this.workflowWakes, this.runs);
|
|
49
66
|
}
|
|
50
67
|
databasePath;
|
|
68
|
+
setTelemetry(telemetry) {
|
|
69
|
+
this.telemetry = telemetry;
|
|
70
|
+
}
|
|
51
71
|
runMigrations() {
|
|
52
72
|
runPatchRelayMigrations(this.connection);
|
|
53
73
|
this.assertSchemaReady();
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { runCli } from "./cli/index.js";
|
|
4
|
+
function suppressNodeSqliteExperimentalWarning() {
|
|
5
|
+
const originalEmitWarning = process.emitWarning;
|
|
6
|
+
process.emitWarning = function emitWarningWithoutNodeSqliteNoise(warning, optionsOrType, codeOrCtor, ctor) {
|
|
7
|
+
const message = warning instanceof Error ? warning.message : warning;
|
|
8
|
+
const type = typeof optionsOrType === "string" ? optionsOrType : optionsOrType?.type;
|
|
9
|
+
if (type === "ExperimentalWarning" && message.includes("SQLite is an experimental feature")) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
return originalEmitWarning.call(process, warning, optionsOrType, codeOrCtor, ctor);
|
|
13
|
+
};
|
|
14
|
+
}
|
|
4
15
|
async function main() {
|
|
16
|
+
suppressNodeSqliteExperimentalWarning();
|
|
5
17
|
const cliExitCode = await runCli(process.argv.slice(2));
|
|
6
18
|
if (cliExitCode !== -1) {
|
|
7
19
|
process.exitCode = cliExitCode;
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { getThreadTurns } from "./codex-thread-utils.js";
|
|
3
|
+
import { emitTelemetry, noopTelemetry } from "./telemetry.js";
|
|
3
4
|
const ISSUE_SESSION_LEASE_MS = 10 * 60_000;
|
|
4
5
|
export class IssueSessionLeaseService {
|
|
5
6
|
db;
|
|
6
7
|
logger;
|
|
7
8
|
workerId;
|
|
8
9
|
readThreadWithRetry;
|
|
10
|
+
telemetry;
|
|
9
11
|
activeSessionLeases = new Map();
|
|
10
|
-
constructor(db, logger, workerId, readThreadWithRetry) {
|
|
12
|
+
constructor(db, logger, workerId, readThreadWithRetry, telemetry = noopTelemetry) {
|
|
11
13
|
this.db = db;
|
|
12
14
|
this.logger = logger;
|
|
13
15
|
this.workerId = workerId;
|
|
14
16
|
this.readThreadWithRetry = readThreadWithRetry;
|
|
17
|
+
this.telemetry = telemetry;
|
|
15
18
|
}
|
|
16
19
|
hasLocalLease(projectId, linearIssueId) {
|
|
17
20
|
return this.getValidatedLocalLeaseId(projectId, linearIssueId) !== undefined;
|
|
@@ -38,9 +41,13 @@ export class IssueSessionLeaseService {
|
|
|
38
41
|
workerId: this.workerId,
|
|
39
42
|
leasedUntil,
|
|
40
43
|
});
|
|
41
|
-
if (!acquired)
|
|
44
|
+
if (!acquired) {
|
|
45
|
+
this.emitLease("lease.acquire_failed", projectId, linearIssueId);
|
|
46
|
+
this.emitStaleLeaseInvariantIfRunnable(projectId, linearIssueId);
|
|
42
47
|
return undefined;
|
|
48
|
+
}
|
|
43
49
|
this.activeSessionLeases.set(this.issueSessionLeaseKey(projectId, linearIssueId), leaseId);
|
|
50
|
+
this.emitLease("lease.acquired", projectId, linearIssueId, leaseId);
|
|
44
51
|
return leaseId;
|
|
45
52
|
}
|
|
46
53
|
forceAcquire(projectId, linearIssueId) {
|
|
@@ -53,9 +60,12 @@ export class IssueSessionLeaseService {
|
|
|
53
60
|
workerId: this.workerId,
|
|
54
61
|
leasedUntil,
|
|
55
62
|
});
|
|
56
|
-
if (!acquired)
|
|
63
|
+
if (!acquired) {
|
|
64
|
+
this.emitLease("lease.acquire_failed", projectId, linearIssueId);
|
|
57
65
|
return undefined;
|
|
66
|
+
}
|
|
58
67
|
this.activeSessionLeases.set(this.issueSessionLeaseKey(projectId, linearIssueId), leaseId);
|
|
68
|
+
this.emitLease("lease.acquired", projectId, linearIssueId, leaseId);
|
|
59
69
|
return leaseId;
|
|
60
70
|
}
|
|
61
71
|
claimForReconciliation(projectId, linearIssueId) {
|
|
@@ -68,8 +78,12 @@ export class IssueSessionLeaseService {
|
|
|
68
78
|
return "skip";
|
|
69
79
|
const leasedUntilMs = session.leasedUntil ? Date.parse(session.leasedUntil) : undefined;
|
|
70
80
|
if (leasedUntilMs !== undefined && Number.isFinite(leasedUntilMs) && leasedUntilMs > Date.now()) {
|
|
81
|
+
this.emitStaleLeaseInvariantIfRunnable(projectId, linearIssueId);
|
|
71
82
|
return "skip";
|
|
72
83
|
}
|
|
84
|
+
if (session.leaseId) {
|
|
85
|
+
this.emitLease("lease.expired", projectId, linearIssueId, session.leaseId);
|
|
86
|
+
}
|
|
73
87
|
return this.acquire(projectId, linearIssueId) ? true : "skip";
|
|
74
88
|
}
|
|
75
89
|
async reclaimForeignRecoveryLeaseIfSafe(run, issue) {
|
|
@@ -111,6 +125,16 @@ export class IssueSessionLeaseService {
|
|
|
111
125
|
previousLeaseId: session.leaseId,
|
|
112
126
|
reclaimedLeaseId: leaseId,
|
|
113
127
|
}, "Reclaimed foreign issue-session lease for active-run recovery");
|
|
128
|
+
emitTelemetry(this.telemetry, {
|
|
129
|
+
type: "lease.reclaimed",
|
|
130
|
+
projectId: run.projectId,
|
|
131
|
+
linearIssueId: run.linearIssueId,
|
|
132
|
+
issueKey: issue.issueKey,
|
|
133
|
+
runId: run.id,
|
|
134
|
+
runType: run.runType,
|
|
135
|
+
leaseId,
|
|
136
|
+
workerId: this.workerId,
|
|
137
|
+
});
|
|
114
138
|
return true;
|
|
115
139
|
}
|
|
116
140
|
heartbeat(projectId, linearIssueId) {
|
|
@@ -136,6 +160,7 @@ export class IssueSessionLeaseService {
|
|
|
136
160
|
const leaseId = this.getValidatedLocalLeaseId(projectId, linearIssueId);
|
|
137
161
|
this.db.issueSessions.releaseIssueSessionLease(projectId, linearIssueId, leaseId);
|
|
138
162
|
this.activeSessionLeases.delete(key);
|
|
163
|
+
this.emitLease("lease.released", projectId, linearIssueId, leaseId);
|
|
139
164
|
}
|
|
140
165
|
getValidatedLocalLeaseId(projectId, linearIssueId) {
|
|
141
166
|
const key = this.issueSessionLeaseKey(projectId, linearIssueId);
|
|
@@ -151,4 +176,33 @@ export class IssueSessionLeaseService {
|
|
|
151
176
|
issueSessionLeaseKey(projectId, linearIssueId) {
|
|
152
177
|
return `${projectId}:${linearIssueId}`;
|
|
153
178
|
}
|
|
179
|
+
emitLease(type, projectId, linearIssueId, leaseId) {
|
|
180
|
+
const issue = this.db.issues.getIssue(projectId, linearIssueId);
|
|
181
|
+
emitTelemetry(this.telemetry, {
|
|
182
|
+
type,
|
|
183
|
+
projectId,
|
|
184
|
+
linearIssueId,
|
|
185
|
+
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
186
|
+
...(issue?.activeRunId ? { runId: issue.activeRunId } : {}),
|
|
187
|
+
...(leaseId ? { leaseId } : {}),
|
|
188
|
+
workerId: this.workerId,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
emitStaleLeaseInvariantIfRunnable(projectId, linearIssueId) {
|
|
192
|
+
const issue = this.db.issues.getIssue(projectId, linearIssueId);
|
|
193
|
+
const wake = this.db.workflowWakes.peekIssueWake(projectId, linearIssueId);
|
|
194
|
+
const runType = wake?.runType ?? issue?.pendingRunType;
|
|
195
|
+
if (!runType)
|
|
196
|
+
return;
|
|
197
|
+
emitTelemetry(this.telemetry, {
|
|
198
|
+
type: "health.invariant",
|
|
199
|
+
invariant: "stale_lease_blocking_runnable_work",
|
|
200
|
+
status: "observed",
|
|
201
|
+
projectId,
|
|
202
|
+
linearIssueId,
|
|
203
|
+
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
204
|
+
runType,
|
|
205
|
+
detail: "Runnable work could not acquire an issue-session lease",
|
|
206
|
+
});
|
|
207
|
+
}
|
|
154
208
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { emitTelemetry, noopTelemetry } from "./telemetry.js";
|
|
2
|
+
export class ImmediateIssueSessionProjectionInvalidator {
|
|
3
|
+
deps;
|
|
4
|
+
constructor(deps) {
|
|
5
|
+
this.deps = deps;
|
|
6
|
+
}
|
|
7
|
+
issueChanged(issue, options) {
|
|
8
|
+
const dependents = this.deps.listDependents(issue.projectId, issue.linearIssueId);
|
|
9
|
+
this.emitInvalidated("issue_changed", issue.projectId, issue.linearIssueId, issue.issueKey, 1 + dependents.length);
|
|
10
|
+
this.projectIssue(issue, "issue_changed", options);
|
|
11
|
+
for (const dependent of dependents) {
|
|
12
|
+
this.projectIssueById(dependent.projectId, dependent.linearIssueId, "issue_changed");
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
issueRunChanged(issue, options) {
|
|
16
|
+
this.emitInvalidated("issue_run_changed", issue.projectId, issue.linearIssueId, issue.issueKey, 1);
|
|
17
|
+
this.projectIssue(issue, "issue_run_changed", options);
|
|
18
|
+
}
|
|
19
|
+
issueDependenciesChanged(projectId, linearIssueId) {
|
|
20
|
+
this.emitInvalidated("issue_dependencies_changed", projectId, linearIssueId, undefined, 1);
|
|
21
|
+
this.projectIssueById(projectId, linearIssueId, "issue_dependencies_changed");
|
|
22
|
+
}
|
|
23
|
+
dependencyBlockerChanged(projectId, blockerLinearIssueId) {
|
|
24
|
+
const dependents = this.deps.listDependents(projectId, blockerLinearIssueId);
|
|
25
|
+
this.emitInvalidated("dependency_blocker_changed", projectId, blockerLinearIssueId, undefined, dependents.length);
|
|
26
|
+
for (const dependent of dependents) {
|
|
27
|
+
this.projectIssueById(dependent.projectId, dependent.linearIssueId, "dependency_blocker_changed");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
issueSessionEventsChanged(projectId, linearIssueId) {
|
|
31
|
+
this.emitInvalidated("issue_session_events_changed", projectId, linearIssueId, undefined, 1);
|
|
32
|
+
this.projectIssueById(projectId, linearIssueId, "issue_session_events_changed");
|
|
33
|
+
}
|
|
34
|
+
projectIssueById(projectId, linearIssueId, reason) {
|
|
35
|
+
const issue = this.deps.getIssue(projectId, linearIssueId);
|
|
36
|
+
if (issue) {
|
|
37
|
+
this.projectIssue(issue, reason);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
projectIssue(issue, reason, options) {
|
|
41
|
+
const beforeWaitingReason = this.deps.getIssueSessionWaitingReason?.(issue.projectId, issue.linearIssueId);
|
|
42
|
+
this.deps.projectIssue(issue, options);
|
|
43
|
+
this.emitReprojected(reason, issue);
|
|
44
|
+
const unresolved = this.deps.countUnresolvedBlockers?.(issue.projectId, issue.linearIssueId);
|
|
45
|
+
const afterWaitingReason = this.deps.getIssueSessionWaitingReason?.(issue.projectId, issue.linearIssueId);
|
|
46
|
+
if (unresolved === 0
|
|
47
|
+
&& beforeWaitingReason?.startsWith("Blocked by ")
|
|
48
|
+
&& !afterWaitingReason?.startsWith("Blocked by ")) {
|
|
49
|
+
emitTelemetry(this.deps.telemetry ?? noopTelemetry, {
|
|
50
|
+
type: "health.invariant",
|
|
51
|
+
invariant: "stale_blocked_read_model",
|
|
52
|
+
status: "repaired",
|
|
53
|
+
projectId: issue.projectId,
|
|
54
|
+
linearIssueId: issue.linearIssueId,
|
|
55
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
56
|
+
detail: "Projection cleared stale blocked waiting reason after blockers resolved",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
else if (unresolved === 0 && afterWaitingReason?.startsWith("Blocked by ")) {
|
|
60
|
+
emitTelemetry(this.deps.telemetry ?? noopTelemetry, {
|
|
61
|
+
type: "health.invariant",
|
|
62
|
+
invariant: "stale_blocked_read_model",
|
|
63
|
+
status: "observed",
|
|
64
|
+
projectId: issue.projectId,
|
|
65
|
+
linearIssueId: issue.linearIssueId,
|
|
66
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
67
|
+
detail: "Issue session still reports blocked after source blockers resolved",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
emitInvalidated(reason, projectId, linearIssueId, issueKey, affectedCount) {
|
|
72
|
+
emitTelemetry(this.deps.telemetry ?? noopTelemetry, {
|
|
73
|
+
type: "projection.invalidated",
|
|
74
|
+
reason,
|
|
75
|
+
projectId,
|
|
76
|
+
linearIssueId,
|
|
77
|
+
...(issueKey ? { issueKey } : {}),
|
|
78
|
+
affectedCount,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
emitReprojected(reason, issue) {
|
|
82
|
+
emitTelemetry(this.deps.telemetry ?? noopTelemetry, {
|
|
83
|
+
type: "projection.reprojected",
|
|
84
|
+
reason,
|
|
85
|
+
projectId: issue.projectId,
|
|
86
|
+
linearIssueId: issue.linearIssueId,
|
|
87
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
package/dist/preflight.js
CHANGED
|
@@ -117,6 +117,9 @@ async function checkLinearApi(graphqlUrl) {
|
|
|
117
117
|
if (response.ok) {
|
|
118
118
|
return pass("linear_api", `Linear GraphQL API is reachable at ${graphqlUrl}`);
|
|
119
119
|
}
|
|
120
|
+
if (response.status === 401 || response.status === 403) {
|
|
121
|
+
return pass("linear_api", `Linear GraphQL API is reachable at ${graphqlUrl} (authentication required)`);
|
|
122
|
+
}
|
|
120
123
|
return warn("linear_api", `Linear GraphQL API returned ${response.status} — may be unreachable or rate-limited`);
|
|
121
124
|
}
|
|
122
125
|
catch (error) {
|