patchrelay 0.1.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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +271 -0
  3. package/config/patchrelay.example.json +5 -0
  4. package/dist/build-info.js +29 -0
  5. package/dist/build-info.json +6 -0
  6. package/dist/cli/data.js +461 -0
  7. package/dist/cli/formatters/json.js +3 -0
  8. package/dist/cli/formatters/text.js +119 -0
  9. package/dist/cli/index.js +761 -0
  10. package/dist/codex-app-server.js +353 -0
  11. package/dist/codex-types.js +1 -0
  12. package/dist/config-types.js +1 -0
  13. package/dist/config.js +494 -0
  14. package/dist/db/authoritative-ledger-store.js +437 -0
  15. package/dist/db/issue-workflow-store.js +690 -0
  16. package/dist/db/linear-installation-store.js +184 -0
  17. package/dist/db/migrations.js +183 -0
  18. package/dist/db/shared.js +101 -0
  19. package/dist/db/stage-event-store.js +33 -0
  20. package/dist/db/webhook-event-store.js +46 -0
  21. package/dist/db-ports.js +5 -0
  22. package/dist/db-types.js +1 -0
  23. package/dist/db.js +40 -0
  24. package/dist/file-permissions.js +40 -0
  25. package/dist/http.js +321 -0
  26. package/dist/index.js +69 -0
  27. package/dist/install.js +302 -0
  28. package/dist/installation-ports.js +1 -0
  29. package/dist/issue-query-service.js +68 -0
  30. package/dist/ledger-ports.js +1 -0
  31. package/dist/linear-client.js +338 -0
  32. package/dist/linear-oauth-service.js +131 -0
  33. package/dist/linear-oauth.js +154 -0
  34. package/dist/linear-types.js +1 -0
  35. package/dist/linear-workflow.js +78 -0
  36. package/dist/logging.js +62 -0
  37. package/dist/preflight.js +227 -0
  38. package/dist/project-resolution.js +51 -0
  39. package/dist/reconciliation-action-applier.js +55 -0
  40. package/dist/reconciliation-actions.js +1 -0
  41. package/dist/reconciliation-engine.js +312 -0
  42. package/dist/reconciliation-snapshot-builder.js +96 -0
  43. package/dist/reconciliation-types.js +1 -0
  44. package/dist/runtime-paths.js +89 -0
  45. package/dist/service-queue.js +49 -0
  46. package/dist/service-runtime.js +96 -0
  47. package/dist/service-stage-finalizer.js +348 -0
  48. package/dist/service-stage-runner.js +233 -0
  49. package/dist/service-webhook-processor.js +181 -0
  50. package/dist/service-webhooks.js +148 -0
  51. package/dist/service.js +139 -0
  52. package/dist/stage-agent-activity-publisher.js +33 -0
  53. package/dist/stage-event-ports.js +1 -0
  54. package/dist/stage-failure.js +92 -0
  55. package/dist/stage-launch.js +54 -0
  56. package/dist/stage-lifecycle-publisher.js +213 -0
  57. package/dist/stage-reporting.js +153 -0
  58. package/dist/stage-turn-input-dispatcher.js +102 -0
  59. package/dist/token-crypto.js +21 -0
  60. package/dist/types.js +5 -0
  61. package/dist/utils.js +163 -0
  62. package/dist/webhook-agent-session-handler.js +157 -0
  63. package/dist/webhook-archive.js +24 -0
  64. package/dist/webhook-comment-handler.js +89 -0
  65. package/dist/webhook-desired-stage-recorder.js +150 -0
  66. package/dist/webhook-event-ports.js +1 -0
  67. package/dist/webhook-installation-handler.js +57 -0
  68. package/dist/webhooks.js +301 -0
  69. package/dist/workflow-policy.js +42 -0
  70. package/dist/workflow-ports.js +1 -0
  71. package/dist/workflow-types.js +1 -0
  72. package/dist/worktree-manager.js +66 -0
  73. package/infra/patchrelay-reload.service +6 -0
  74. package/infra/patchrelay.path +11 -0
  75. package/infra/patchrelay.service +28 -0
  76. package/package.json +55 -0
  77. package/runtime.env.example +8 -0
  78. package/service.env.example +7 -0
@@ -0,0 +1,184 @@
1
+ import { isoNow } from "./shared.js";
2
+ export class LinearInstallationStore {
3
+ connection;
4
+ constructor(connection) {
5
+ this.connection = connection;
6
+ }
7
+ upsertLinearInstallation(params) {
8
+ const now = isoNow();
9
+ const existing = params.workspaceId
10
+ ? this.connection
11
+ .prepare("SELECT id FROM linear_installations WHERE workspace_id = ? ORDER BY id DESC LIMIT 1")
12
+ .get(params.workspaceId)
13
+ : undefined;
14
+ if (existing) {
15
+ this.connection
16
+ .prepare(`
17
+ UPDATE linear_installations
18
+ SET workspace_name = COALESCE(?, workspace_name),
19
+ workspace_key = COALESCE(?, workspace_key),
20
+ actor_id = COALESCE(?, actor_id),
21
+ actor_name = COALESCE(?, actor_name),
22
+ access_token_ciphertext = ?,
23
+ refresh_token_ciphertext = COALESCE(?, refresh_token_ciphertext),
24
+ scopes_json = ?,
25
+ token_type = COALESCE(?, token_type),
26
+ expires_at = COALESCE(?, expires_at),
27
+ updated_at = ?
28
+ WHERE id = ?
29
+ `)
30
+ .run(params.workspaceName ?? null, params.workspaceKey ?? null, params.actorId ?? null, params.actorName ?? null, params.accessTokenCiphertext, params.refreshTokenCiphertext ?? null, params.scopesJson, params.tokenType ?? null, params.expiresAt ?? null, now, existing.id);
31
+ return this.getLinearInstallation(existing.id);
32
+ }
33
+ const result = this.connection
34
+ .prepare(`
35
+ INSERT INTO linear_installations (
36
+ workspace_id, workspace_name, workspace_key, actor_id, actor_name,
37
+ access_token_ciphertext, refresh_token_ciphertext, scopes_json, token_type, expires_at, created_at, updated_at
38
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
39
+ `)
40
+ .run(params.workspaceId ?? null, params.workspaceName ?? null, params.workspaceKey ?? null, params.actorId ?? null, params.actorName ?? null, params.accessTokenCiphertext, params.refreshTokenCiphertext ?? null, params.scopesJson, params.tokenType ?? null, params.expiresAt ?? null, now, now);
41
+ return this.getLinearInstallation(Number(result.lastInsertRowid));
42
+ }
43
+ saveLinearInstallation(params) {
44
+ return this.upsertLinearInstallation(params);
45
+ }
46
+ updateLinearInstallationTokens(id, params) {
47
+ this.connection
48
+ .prepare(`
49
+ UPDATE linear_installations
50
+ SET access_token_ciphertext = ?,
51
+ refresh_token_ciphertext = COALESCE(?, refresh_token_ciphertext),
52
+ scopes_json = COALESCE(?, scopes_json),
53
+ token_type = COALESCE(?, token_type),
54
+ expires_at = COALESCE(?, expires_at),
55
+ updated_at = ?
56
+ WHERE id = ?
57
+ `)
58
+ .run(params.accessTokenCiphertext, params.refreshTokenCiphertext ?? null, params.scopesJson ?? null, params.tokenType ?? null, params.expiresAt ?? null, isoNow(), id);
59
+ return this.getLinearInstallation(id);
60
+ }
61
+ getLinearInstallation(id) {
62
+ const row = this.connection
63
+ .prepare("SELECT * FROM linear_installations WHERE id = ?")
64
+ .get(id);
65
+ return row ? mapLinearInstallation(row) : undefined;
66
+ }
67
+ listLinearInstallations() {
68
+ const rows = this.connection
69
+ .prepare("SELECT * FROM linear_installations ORDER BY updated_at DESC, id DESC")
70
+ .all();
71
+ return rows.map((row) => mapLinearInstallation(row));
72
+ }
73
+ linkProjectInstallation(projectId, installationId) {
74
+ const now = isoNow();
75
+ this.connection
76
+ .prepare(`
77
+ INSERT INTO project_installations (project_id, installation_id, linked_at)
78
+ VALUES (?, ?, ?)
79
+ ON CONFLICT(project_id) DO UPDATE SET installation_id = excluded.installation_id, linked_at = excluded.linked_at
80
+ `)
81
+ .run(projectId, installationId, now);
82
+ return this.getProjectInstallation(projectId);
83
+ }
84
+ setProjectInstallation(projectId, installationId) {
85
+ return this.linkProjectInstallation(projectId, installationId);
86
+ }
87
+ getProjectInstallation(projectId) {
88
+ const row = this.connection
89
+ .prepare("SELECT * FROM project_installations WHERE project_id = ?")
90
+ .get(projectId);
91
+ return row ? mapProjectInstallation(row) : undefined;
92
+ }
93
+ listProjectInstallations() {
94
+ const rows = this.connection
95
+ .prepare("SELECT * FROM project_installations ORDER BY project_id")
96
+ .all();
97
+ return rows.map((row) => mapProjectInstallation(row));
98
+ }
99
+ unlinkProjectInstallation(projectId) {
100
+ this.connection.prepare("DELETE FROM project_installations WHERE project_id = ?").run(projectId);
101
+ }
102
+ getLinearInstallationForProject(projectId) {
103
+ const row = this.connection
104
+ .prepare(`
105
+ SELECT li.*
106
+ FROM linear_installations li
107
+ INNER JOIN project_installations pi ON pi.installation_id = li.id
108
+ WHERE pi.project_id = ?
109
+ `)
110
+ .get(projectId);
111
+ return row ? mapLinearInstallation(row) : undefined;
112
+ }
113
+ createOAuthState(params) {
114
+ const now = isoNow();
115
+ const result = this.connection
116
+ .prepare(`
117
+ INSERT INTO oauth_states (provider, state, project_id, redirect_uri, actor, created_at, status)
118
+ VALUES (?, ?, ?, ?, ?, ?, 'pending')
119
+ `)
120
+ .run(params.provider, params.state, params.projectId ?? null, params.redirectUri, params.actor, now);
121
+ return this.getOAuthStateById(Number(result.lastInsertRowid));
122
+ }
123
+ getOAuthState(state) {
124
+ const row = this.connection
125
+ .prepare("SELECT * FROM oauth_states WHERE state = ? ORDER BY id DESC LIMIT 1")
126
+ .get(state);
127
+ return row ? mapOAuthState(row) : undefined;
128
+ }
129
+ finalizeOAuthState(params) {
130
+ const now = isoNow();
131
+ this.connection
132
+ .prepare(`
133
+ UPDATE oauth_states
134
+ SET status = ?, consumed_at = ?, installation_id = COALESCE(?, installation_id), error_message = COALESCE(?, error_message)
135
+ WHERE state = ?
136
+ `)
137
+ .run(params.status, now, params.installationId ?? null, params.errorMessage ?? null, params.state);
138
+ return this.getOAuthState(params.state);
139
+ }
140
+ getOAuthStateById(id) {
141
+ const row = this.connection.prepare("SELECT * FROM oauth_states WHERE id = ?").get(id);
142
+ return row ? mapOAuthState(row) : undefined;
143
+ }
144
+ }
145
+ function mapLinearInstallation(row) {
146
+ return {
147
+ id: Number(row.id),
148
+ provider: "linear",
149
+ ...(row.workspace_id === null ? {} : { workspaceId: String(row.workspace_id) }),
150
+ ...(row.workspace_name === null ? {} : { workspaceName: String(row.workspace_name) }),
151
+ ...(row.workspace_key === null ? {} : { workspaceKey: String(row.workspace_key) }),
152
+ ...(row.actor_id === null ? {} : { actorId: String(row.actor_id) }),
153
+ ...(row.actor_name === null ? {} : { actorName: String(row.actor_name) }),
154
+ accessTokenCiphertext: String(row.access_token_ciphertext),
155
+ ...(row.refresh_token_ciphertext === null ? {} : { refreshTokenCiphertext: String(row.refresh_token_ciphertext) }),
156
+ scopesJson: String(row.scopes_json),
157
+ ...(row.token_type === null ? {} : { tokenType: String(row.token_type) }),
158
+ ...(row.expires_at === null ? {} : { expiresAt: String(row.expires_at) }),
159
+ createdAt: String(row.created_at),
160
+ updatedAt: String(row.updated_at),
161
+ };
162
+ }
163
+ function mapProjectInstallation(row) {
164
+ return {
165
+ projectId: String(row.project_id),
166
+ installationId: Number(row.installation_id),
167
+ linkedAt: String(row.linked_at),
168
+ };
169
+ }
170
+ function mapOAuthState(row) {
171
+ return {
172
+ id: Number(row.id),
173
+ provider: "linear",
174
+ state: String(row.state),
175
+ ...(row.project_id === null ? {} : { projectId: String(row.project_id) }),
176
+ redirectUri: String(row.redirect_uri),
177
+ actor: row.actor,
178
+ createdAt: String(row.created_at),
179
+ status: row.status ?? "pending",
180
+ ...(row.consumed_at === null ? {} : { consumedAt: String(row.consumed_at) }),
181
+ ...(row.installation_id === null ? {} : { installationId: Number(row.installation_id) }),
182
+ ...(row.error_message === null ? {} : { errorMessage: String(row.error_message) }),
183
+ };
184
+ }
@@ -0,0 +1,183 @@
1
+ const baseMigration = `
2
+ CREATE TABLE IF NOT EXISTS webhook_events (
3
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
4
+ webhook_id TEXT NOT NULL UNIQUE,
5
+ received_at TEXT NOT NULL,
6
+ event_type TEXT NOT NULL,
7
+ issue_id TEXT,
8
+ project_id TEXT,
9
+ headers_json TEXT NOT NULL,
10
+ payload_json TEXT NOT NULL,
11
+ signature_valid INTEGER NOT NULL,
12
+ dedupe_status TEXT NOT NULL,
13
+ processing_status TEXT NOT NULL DEFAULT 'pending'
14
+ );
15
+
16
+ CREATE TABLE IF NOT EXISTS event_receipts (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ source TEXT NOT NULL,
19
+ external_id TEXT NOT NULL,
20
+ event_type TEXT NOT NULL,
21
+ received_at TEXT NOT NULL,
22
+ acceptance_status TEXT NOT NULL,
23
+ processing_status TEXT NOT NULL DEFAULT 'pending',
24
+ project_id TEXT,
25
+ linear_issue_id TEXT,
26
+ headers_json TEXT,
27
+ payload_json TEXT,
28
+ UNIQUE(source, external_id)
29
+ );
30
+
31
+ CREATE TABLE IF NOT EXISTS issue_control (
32
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
33
+ project_id TEXT NOT NULL,
34
+ linear_issue_id TEXT NOT NULL,
35
+ desired_stage TEXT,
36
+ desired_receipt_id INTEGER,
37
+ active_run_lease_id INTEGER,
38
+ active_workspace_ownership_id INTEGER,
39
+ service_owned_comment_id TEXT,
40
+ active_agent_session_id TEXT,
41
+ lifecycle_status TEXT NOT NULL,
42
+ updated_at TEXT NOT NULL,
43
+ UNIQUE(project_id, linear_issue_id)
44
+ );
45
+
46
+ CREATE TABLE IF NOT EXISTS issue_projection (
47
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
48
+ project_id TEXT NOT NULL,
49
+ linear_issue_id TEXT NOT NULL,
50
+ issue_key TEXT,
51
+ title TEXT,
52
+ issue_url TEXT,
53
+ current_linear_state TEXT,
54
+ last_webhook_at TEXT,
55
+ updated_at TEXT NOT NULL,
56
+ UNIQUE(project_id, linear_issue_id)
57
+ );
58
+
59
+ CREATE TABLE IF NOT EXISTS workspace_ownership (
60
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
61
+ project_id TEXT NOT NULL,
62
+ linear_issue_id TEXT NOT NULL,
63
+ branch_name TEXT NOT NULL,
64
+ worktree_path TEXT NOT NULL,
65
+ status TEXT NOT NULL,
66
+ current_run_lease_id INTEGER,
67
+ created_at TEXT NOT NULL,
68
+ updated_at TEXT NOT NULL,
69
+ UNIQUE(project_id, linear_issue_id)
70
+ );
71
+
72
+ CREATE TABLE IF NOT EXISTS run_leases (
73
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
74
+ issue_control_id INTEGER NOT NULL,
75
+ project_id TEXT NOT NULL,
76
+ linear_issue_id TEXT NOT NULL,
77
+ workspace_ownership_id INTEGER NOT NULL,
78
+ stage TEXT NOT NULL,
79
+ status TEXT NOT NULL,
80
+ trigger_receipt_id INTEGER,
81
+ workflow_file TEXT NOT NULL DEFAULT '',
82
+ prompt_text TEXT NOT NULL DEFAULT '',
83
+ thread_id TEXT,
84
+ parent_thread_id TEXT,
85
+ turn_id TEXT,
86
+ started_at TEXT NOT NULL,
87
+ ended_at TEXT,
88
+ failure_reason TEXT,
89
+ FOREIGN KEY(issue_control_id) REFERENCES issue_control(id) ON DELETE CASCADE,
90
+ FOREIGN KEY(workspace_ownership_id) REFERENCES workspace_ownership(id) ON DELETE CASCADE,
91
+ FOREIGN KEY(trigger_receipt_id) REFERENCES event_receipts(id) ON DELETE SET NULL
92
+ );
93
+
94
+ CREATE TABLE IF NOT EXISTS run_reports (
95
+ run_lease_id INTEGER PRIMARY KEY,
96
+ summary_json TEXT,
97
+ report_json TEXT,
98
+ created_at TEXT NOT NULL,
99
+ updated_at TEXT NOT NULL,
100
+ FOREIGN KEY(run_lease_id) REFERENCES run_leases(id) ON DELETE CASCADE
101
+ );
102
+
103
+ CREATE TABLE IF NOT EXISTS run_thread_events (
104
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
105
+ run_lease_id INTEGER NOT NULL,
106
+ thread_id TEXT NOT NULL,
107
+ turn_id TEXT,
108
+ method TEXT NOT NULL,
109
+ event_json TEXT NOT NULL,
110
+ created_at TEXT NOT NULL,
111
+ FOREIGN KEY(run_lease_id) REFERENCES run_leases(id) ON DELETE CASCADE
112
+ );
113
+
114
+ CREATE TABLE IF NOT EXISTS obligations (
115
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
116
+ project_id TEXT NOT NULL,
117
+ linear_issue_id TEXT NOT NULL,
118
+ kind TEXT NOT NULL,
119
+ status TEXT NOT NULL,
120
+ source TEXT NOT NULL,
121
+ payload_json TEXT NOT NULL,
122
+ run_lease_id INTEGER,
123
+ thread_id TEXT,
124
+ turn_id TEXT,
125
+ dedupe_key TEXT,
126
+ last_error TEXT,
127
+ created_at TEXT NOT NULL,
128
+ updated_at TEXT NOT NULL,
129
+ completed_at TEXT,
130
+ FOREIGN KEY(run_lease_id) REFERENCES run_leases(id) ON DELETE SET NULL
131
+ );
132
+
133
+ CREATE TABLE IF NOT EXISTS linear_installations (
134
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
135
+ provider TEXT NOT NULL DEFAULT 'linear',
136
+ workspace_id TEXT,
137
+ workspace_name TEXT,
138
+ workspace_key TEXT,
139
+ actor_id TEXT,
140
+ actor_name TEXT,
141
+ access_token_ciphertext TEXT NOT NULL,
142
+ refresh_token_ciphertext TEXT,
143
+ scopes_json TEXT NOT NULL,
144
+ token_type TEXT,
145
+ expires_at TEXT,
146
+ created_at TEXT NOT NULL,
147
+ updated_at TEXT NOT NULL
148
+ );
149
+
150
+ CREATE TABLE IF NOT EXISTS project_installations (
151
+ project_id TEXT PRIMARY KEY,
152
+ installation_id INTEGER NOT NULL,
153
+ linked_at TEXT NOT NULL
154
+ );
155
+
156
+ CREATE TABLE IF NOT EXISTS oauth_states (
157
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
158
+ provider TEXT NOT NULL,
159
+ state TEXT NOT NULL UNIQUE,
160
+ project_id TEXT,
161
+ redirect_uri TEXT NOT NULL,
162
+ actor TEXT NOT NULL,
163
+ created_at TEXT NOT NULL,
164
+ status TEXT NOT NULL DEFAULT 'pending',
165
+ consumed_at TEXT,
166
+ installation_id INTEGER,
167
+ error_message TEXT
168
+ );
169
+
170
+ CREATE INDEX IF NOT EXISTS idx_event_receipts_project_issue ON event_receipts(project_id, linear_issue_id);
171
+ CREATE INDEX IF NOT EXISTS idx_issue_control_ready ON issue_control(desired_stage, active_run_lease_id);
172
+ CREATE INDEX IF NOT EXISTS idx_issue_projection_issue_key ON issue_projection(issue_key);
173
+ CREATE INDEX IF NOT EXISTS idx_run_leases_active ON run_leases(status, project_id, linear_issue_id);
174
+ CREATE INDEX IF NOT EXISTS idx_run_leases_thread ON run_leases(thread_id);
175
+ CREATE INDEX IF NOT EXISTS idx_run_thread_events_run ON run_thread_events(run_lease_id, id);
176
+ CREATE INDEX IF NOT EXISTS idx_obligations_pending ON obligations(status, run_lease_id, kind);
177
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_obligations_dedupe
178
+ ON obligations(run_lease_id, kind, dedupe_key)
179
+ WHERE dedupe_key IS NOT NULL;
180
+ `;
181
+ export function runPatchRelayMigrations(connection) {
182
+ connection.exec(baseMigration);
183
+ }
@@ -0,0 +1,101 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ function isSqlInputValue(value) {
3
+ return value === null || typeof value === "string" || typeof value === "number" || typeof value === "bigint" || ArrayBuffer.isView(value);
4
+ }
5
+ function toSqlInputValue(value) {
6
+ if (!isSqlInputValue(value)) {
7
+ throw new TypeError(`Unsupported SQLite parameter type: ${typeof value}`);
8
+ }
9
+ return value;
10
+ }
11
+ function toNamedParameters(value) {
12
+ const normalized = {};
13
+ for (const [key, entry] of Object.entries(value)) {
14
+ normalized[key] = toSqlInputValue(entry);
15
+ }
16
+ return normalized;
17
+ }
18
+ function normalizeParameters(parameters) {
19
+ if (parameters.length === 1) {
20
+ const [value] = parameters;
21
+ if (Array.isArray(value)) {
22
+ return value.map(toSqlInputValue);
23
+ }
24
+ if (value && typeof value === "object") {
25
+ return toNamedParameters(value);
26
+ }
27
+ }
28
+ return parameters.map(toSqlInputValue);
29
+ }
30
+ class SqliteStatementAdapter {
31
+ statement;
32
+ constructor(statement) {
33
+ this.statement = statement;
34
+ }
35
+ run(...parameters) {
36
+ const normalized = normalizeParameters(parameters);
37
+ if (Array.isArray(normalized)) {
38
+ return this.statement.run(...normalized);
39
+ }
40
+ return this.statement.run(normalized);
41
+ }
42
+ get(...parameters) {
43
+ const normalized = normalizeParameters(parameters);
44
+ if (Array.isArray(normalized)) {
45
+ return this.statement.get(...normalized);
46
+ }
47
+ return this.statement.get(normalized);
48
+ }
49
+ all(...parameters) {
50
+ const normalized = normalizeParameters(parameters);
51
+ if (Array.isArray(normalized)) {
52
+ return this.statement.all(...normalized);
53
+ }
54
+ return this.statement.all(normalized);
55
+ }
56
+ iterate(...parameters) {
57
+ const normalized = normalizeParameters(parameters);
58
+ if (Array.isArray(normalized)) {
59
+ return this.statement.iterate(...normalized);
60
+ }
61
+ return this.statement.iterate(normalized);
62
+ }
63
+ }
64
+ export class SqliteConnection {
65
+ database;
66
+ savepointId = 0;
67
+ constructor(path) {
68
+ this.database = new DatabaseSync(path);
69
+ }
70
+ close() {
71
+ this.database.close();
72
+ }
73
+ exec(sql) {
74
+ this.database.exec(sql);
75
+ }
76
+ pragma(statement) {
77
+ this.database.exec(`PRAGMA ${statement}`);
78
+ }
79
+ prepare(sql) {
80
+ return new SqliteStatementAdapter(this.database.prepare(sql));
81
+ }
82
+ transaction(fn) {
83
+ return () => {
84
+ const savepoint = `patchrelay_txn_${this.savepointId++}`;
85
+ this.database.exec(`SAVEPOINT ${savepoint}`);
86
+ try {
87
+ const result = fn();
88
+ this.database.exec(`RELEASE SAVEPOINT ${savepoint}`);
89
+ return result;
90
+ }
91
+ catch (error) {
92
+ this.database.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`);
93
+ this.database.exec(`RELEASE SAVEPOINT ${savepoint}`);
94
+ throw error;
95
+ }
96
+ };
97
+ }
98
+ }
99
+ export function isoNow() {
100
+ return new Date().toISOString();
101
+ }
@@ -0,0 +1,33 @@
1
+ import { isoNow } from "./shared.js";
2
+ export class StageEventStore {
3
+ connection;
4
+ constructor(connection) {
5
+ this.connection = connection;
6
+ }
7
+ saveThreadEvent(params) {
8
+ const result = this.connection
9
+ .prepare(`
10
+ INSERT INTO run_thread_events (run_lease_id, thread_id, turn_id, method, event_json, created_at)
11
+ VALUES (?, ?, ?, ?, ?, ?)
12
+ `)
13
+ .run(params.stageRunId, params.threadId, params.turnId ?? null, params.method, params.eventJson, isoNow());
14
+ return Number(result.lastInsertRowid);
15
+ }
16
+ listThreadEvents(stageRunId) {
17
+ const rows = this.connection
18
+ .prepare("SELECT * FROM run_thread_events WHERE run_lease_id = ? ORDER BY id")
19
+ .all(stageRunId);
20
+ return rows.map((row) => mapThreadEvent(row));
21
+ }
22
+ }
23
+ function mapThreadEvent(row) {
24
+ return {
25
+ id: Number(row.id),
26
+ stageRunId: Number(row.run_lease_id),
27
+ threadId: String(row.thread_id),
28
+ ...(row.turn_id === null ? {} : { turnId: String(row.turn_id) }),
29
+ method: String(row.method),
30
+ eventJson: String(row.event_json),
31
+ createdAt: String(row.created_at),
32
+ };
33
+ }
@@ -0,0 +1,46 @@
1
+ export class WebhookEventStore {
2
+ connection;
3
+ constructor(connection) {
4
+ this.connection = connection;
5
+ }
6
+ insertWebhookEvent(params) {
7
+ const existing = this.connection.prepare("SELECT id FROM webhook_events WHERE webhook_id = ?").get(params.webhookId);
8
+ if (existing) {
9
+ this.connection.prepare("UPDATE webhook_events SET dedupe_status = 'duplicate' WHERE id = ?").run(existing.id);
10
+ return { id: existing.id, inserted: false };
11
+ }
12
+ const result = this.connection
13
+ .prepare(`
14
+ INSERT INTO webhook_events (
15
+ webhook_id, received_at, event_type, issue_id, project_id, headers_json, payload_json, signature_valid, dedupe_status
16
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
17
+ `)
18
+ .run(params.webhookId, params.receivedAt, params.eventType, params.issueId ?? null, params.projectId ?? null, params.headersJson, params.payloadJson, params.signatureValid ? 1 : 0, params.dedupeStatus);
19
+ return { id: Number(result.lastInsertRowid), inserted: true };
20
+ }
21
+ markWebhookProcessed(id, status) {
22
+ this.connection.prepare("UPDATE webhook_events SET processing_status = ? WHERE id = ?").run(status, id);
23
+ }
24
+ assignWebhookProject(id, projectId) {
25
+ this.connection.prepare("UPDATE webhook_events SET project_id = ? WHERE id = ?").run(projectId, id);
26
+ }
27
+ getWebhookEvent(id) {
28
+ const row = this.connection.prepare("SELECT * FROM webhook_events WHERE id = ?").get(id);
29
+ return row ? mapWebhookEvent(row) : undefined;
30
+ }
31
+ }
32
+ function mapWebhookEvent(row) {
33
+ return {
34
+ id: Number(row.id),
35
+ webhookId: String(row.webhook_id),
36
+ receivedAt: String(row.received_at),
37
+ eventType: String(row.event_type),
38
+ ...(row.issue_id === null ? {} : { issueId: String(row.issue_id) }),
39
+ ...(row.project_id === null ? {} : { projectId: String(row.project_id) }),
40
+ headersJson: String(row.headers_json),
41
+ payloadJson: String(row.payload_json),
42
+ signatureValid: Number(row.signature_valid) === 1,
43
+ dedupeStatus: row.dedupe_status,
44
+ processingStatus: row.processing_status,
45
+ };
46
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./webhook-event-ports.js";
2
+ export * from "./installation-ports.js";
3
+ export * from "./ledger-ports.js";
4
+ export * from "./stage-event-ports.js";
5
+ export * from "./workflow-ports.js";
@@ -0,0 +1 @@
1
+ export {};
package/dist/db.js ADDED
@@ -0,0 +1,40 @@
1
+ import { AuthoritativeLedgerStore } from "./db/authoritative-ledger-store.js";
2
+ import { IssueWorkflowStore } from "./db/issue-workflow-store.js";
3
+ import { LinearInstallationStore } from "./db/linear-installation-store.js";
4
+ import { runPatchRelayMigrations } from "./db/migrations.js";
5
+ import { StageEventStore } from "./db/stage-event-store.js";
6
+ import { SqliteConnection } from "./db/shared.js";
7
+ import { WebhookEventStore } from "./db/webhook-event-store.js";
8
+ export class PatchRelayDatabase {
9
+ connection;
10
+ authoritativeLedger;
11
+ eventReceipts;
12
+ issueControl;
13
+ workspaceOwnership;
14
+ runLeases;
15
+ obligations;
16
+ webhookEvents;
17
+ issueWorkflows;
18
+ stageEvents;
19
+ linearInstallations;
20
+ constructor(databasePath, wal) {
21
+ this.connection = new SqliteConnection(databasePath);
22
+ this.connection.pragma("foreign_keys = ON");
23
+ if (wal) {
24
+ this.connection.pragma("journal_mode = WAL");
25
+ }
26
+ this.authoritativeLedger = new AuthoritativeLedgerStore(this.connection);
27
+ this.eventReceipts = this.authoritativeLedger;
28
+ this.issueControl = this.authoritativeLedger;
29
+ this.workspaceOwnership = this.authoritativeLedger;
30
+ this.runLeases = this.authoritativeLedger;
31
+ this.obligations = this.authoritativeLedger;
32
+ this.webhookEvents = new WebhookEventStore(this.connection);
33
+ this.issueWorkflows = new IssueWorkflowStore(this.connection);
34
+ this.stageEvents = new StageEventStore(this.connection);
35
+ this.linearInstallations = new LinearInstallationStore(this.connection);
36
+ }
37
+ runMigrations() {
38
+ runPatchRelayMigrations(this.connection);
39
+ }
40
+ }
@@ -0,0 +1,40 @@
1
+ import { chmod, open } from "node:fs/promises";
2
+ export const SERVICE_ENV_FILE_MODE = 0o600;
3
+ export const DATABASE_FILE_MODE = 0o600;
4
+ export const LOG_FILE_MODE = 0o640;
5
+ async function setMode(filePath, mode) {
6
+ if (process.platform === "win32") {
7
+ return;
8
+ }
9
+ await chmod(filePath, mode);
10
+ }
11
+ async function enforceFileMode(filePath, mode, options) {
12
+ if (options?.create) {
13
+ const handle = await open(filePath, "a", mode);
14
+ await handle.close();
15
+ }
16
+ try {
17
+ await setMode(filePath, mode);
18
+ }
19
+ catch (error) {
20
+ if (error &&
21
+ typeof error === "object" &&
22
+ "code" in error &&
23
+ error.code === "ENOENT") {
24
+ return;
25
+ }
26
+ throw error;
27
+ }
28
+ }
29
+ export async function enforceArbitraryFilePermissions(filePath, mode, options) {
30
+ await enforceFileMode(filePath, mode, options);
31
+ }
32
+ export async function enforceServiceEnvPermissions(serviceEnvPath) {
33
+ await enforceFileMode(serviceEnvPath, SERVICE_ENV_FILE_MODE);
34
+ }
35
+ export async function enforceRuntimeFilePermissions(config) {
36
+ await enforceFileMode(config.database.path, DATABASE_FILE_MODE, { create: true });
37
+ await enforceFileMode(config.logging.filePath, LOG_FILE_MODE, { create: true });
38
+ await enforceFileMode(`${config.database.path}-wal`, DATABASE_FILE_MODE);
39
+ await enforceFileMode(`${config.database.path}-shm`, DATABASE_FILE_MODE);
40
+ }