agents-relay 2.0.97 → 2.0.98
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/cli-help.js +11 -11
- package/dist/cli-job-create.js +19 -11
- package/dist/cli.js +3 -3
- package/dist/job-task-id-sequence.js +10 -19
- package/dist/local-job-registry.js +5 -4
- package/dist/local-managed-store.js +5 -3
- package/dist/projection-outbox.js +62 -37
- package/dist/relay-store-discovery.js +93 -0
- package/dist/relay-store.js +93 -70
- package/dist/relayd-control.js +5 -5
- package/dist/relayd.js +8 -8
- package/dist/service-publication.js +2 -2
- package/dist/unified-job-registry.js +33 -0
- package/dist/unified-pr-facts.js +11 -0
- package/package.json +1 -1
package/dist/cli-help.js
CHANGED
|
@@ -46,7 +46,7 @@ Common options:
|
|
|
46
46
|
--pr NUMBER Existing PR number
|
|
47
47
|
|
|
48
48
|
Example:
|
|
49
|
-
npx agents-relay job show --repo OWNER/REPO --pr 12 --id
|
|
49
|
+
npx agents-relay job show --repo OWNER/REPO --pr 12 --id 101`,
|
|
50
50
|
review: `Request a new SHA-bound reviewer turn through the existing Relay lifecycle.
|
|
51
51
|
|
|
52
52
|
Usage: npx agents-relay review request --repo OWNER/REPO --pr NUMBER --id JOB_ID [--thread-id THREAD_ID]
|
|
@@ -58,15 +58,15 @@ Required: --repo OWNER/REPO --pr NUMBER --id JOB_ID.
|
|
|
58
58
|
Optional: --thread-id THREAD_ID persists a reviewer override; omitted uses Job reviewer/creator thread or legacy route. Rejects pending reviews and wakes managed reconciliation.`,
|
|
59
59
|
'job create': `Create a SQLite-authoritative Job (objective-only).
|
|
60
60
|
|
|
61
|
-
Required: --repo OWNER/REPO and a standalone Job objective. Job ID defaults to an auto-incrementing store-global number; --id
|
|
61
|
+
Required: --repo OWNER/REPO and a standalone Job objective. Job ID defaults to an auto-incrementing store-global number; --id may only reference an existing numeric Job. Existing PR adoption is an explicit separate command: job adopt.
|
|
62
62
|
Common: --id JOB_ID (optional), --title TEXT, --objective-file PATH|- (preferred), --objective MARKDOWN (short/simple only), --project-path RELATIVE_PATH, --priority P0|P1|P2|P3, --mode fixed|autonomous, --initial-plan, --no-review, --tag JSON, --reviewer-thread-id ID, --creator-thread-id ID, --implementation-thread-id ID. --objective and --objective-file are mutually exclusive.
|
|
63
63
|
Job creation always enables auto-merge. By default it is create-only; --initial-plan validates the first real Planner round before atomically creating the durable Job and Plan, then publishes the normal wake so Relay can continue without a second orchestrator command. Re-running create against an already healthy Job is idempotent and does not mutate its metadata.
|
|
64
64
|
|
|
65
65
|
Example:
|
|
66
66
|
npx agents-relay job create --repo OWNER/REPO --project-path vlog --title "Episode" --objective-file episode.md --initial-plan`,
|
|
67
|
-
'job adopt': `Adopt an existing
|
|
67
|
+
'job adopt': `Adopt an existing PR as a SQLite-authoritative Job.
|
|
68
68
|
|
|
69
|
-
Required: --repo OWNER/REPO
|
|
69
|
+
Required: --repo OWNER/REPO and --pr NUMBER. The Job ID is allocated atomically from the global SQLite sequence. Optional --id references an already-existing numeric Job only.`,
|
|
70
70
|
'job update': `Partially update mutable metadata on a healthy existing Job.
|
|
71
71
|
|
|
72
72
|
Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
|
|
@@ -74,13 +74,13 @@ Mutable: --title, --objective-file PATH|- (preferred) or --objective MARKDOWN, -
|
|
|
74
74
|
Unspecified fields are preserved. Local Job/PR identity mismatch is rejected.
|
|
75
75
|
|
|
76
76
|
Example:
|
|
77
|
-
npx agents-relay job update --repo OWNER/REPO --pr 12 --id
|
|
77
|
+
npx agents-relay job update --repo OWNER/REPO --pr 12 --id 101 --priority P0 --no-auto-merge`,
|
|
78
78
|
'job show': `Show one durable Job.
|
|
79
79
|
|
|
80
80
|
Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
|
|
81
81
|
|
|
82
82
|
Example:
|
|
83
|
-
npx agents-relay job show --repo OWNER/REPO --pr 12 --id
|
|
83
|
+
npx agents-relay job show --repo OWNER/REPO --pr 12 --id 101`,
|
|
84
84
|
'job list': `List managed Jobs in a repository.
|
|
85
85
|
|
|
86
86
|
Required: --repo OWNER/REPO.
|
|
@@ -130,7 +130,7 @@ Mutable while not RUNNING/SUCCEEDED/CANCELLED: --title, --adapter, --priority, -
|
|
|
130
130
|
Use --clear-routing to remove routing metadata.
|
|
131
131
|
|
|
132
132
|
Example:
|
|
133
|
-
npx agents-relay task update --repo OWNER/REPO --pr 12 --id
|
|
133
|
+
npx agents-relay task update --repo OWNER/REPO --pr 12 --id 101 --task-id build --adapter chatgpt --provider chatgpt --model gpt-5.6-sol --reasoning high`,
|
|
134
134
|
'task comment': `Append an immutable Task comment. Does not retry or change Task state.
|
|
135
135
|
|
|
136
136
|
Required: --repo OWNER/REPO --id JOB_ID --task-id TASK_ID and --message TEXT or --message-file FILE.`,
|
|
@@ -166,7 +166,7 @@ Optional: --prompt TEXT or --prompt-file PATH|- (mutually exclusive). The prompt
|
|
|
166
166
|
The command persists one real plan-N round, materializes validated next_tasks with planner provenance, and wakes normal reconciliation. It works for zero-task DRAFT Jobs. Historical planner-N rounds remain readable, but new rounds use plan-N.
|
|
167
167
|
|
|
168
168
|
Example:
|
|
169
|
-
npx agents-relay plan --repo OWNER/REPO --pr 12 --id
|
|
169
|
+
npx agents-relay plan --repo OWNER/REPO --pr 12 --id 101 --prompt "Plan the full implementation now; keep independent work parallel."`,
|
|
170
170
|
blocker: `Manage durable cross-Job blocker dependencies.
|
|
171
171
|
|
|
172
172
|
Usage:
|
|
@@ -211,20 +211,20 @@ Required: --file PATH for local demo mode, or --repo OWNER/REPO --pr NUMBER --id
|
|
|
211
211
|
Managed GitHub jobs are reconciled by agents-relayd. The CLI publishes a job.wake request and does not launch workers; NATS is auto-detected when reachable; use --events nats to require it explicitly. Local/runtime invocations may use --thread-id ID to override the durable reviewer/creator thread for ChatGPT review delivery.
|
|
212
212
|
|
|
213
213
|
Example:
|
|
214
|
-
npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id
|
|
214
|
+
npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id 101`,
|
|
215
215
|
init: `Initialize a new job. GitHub mode requires --repo and --pr; local mode requires explicit --file.
|
|
216
216
|
|
|
217
217
|
Common: --id ID, --title TEXT, --objective TEXT, --mode fixed|autonomous, --priority P0|P1|P2|P3.
|
|
218
218
|
|
|
219
219
|
Example:
|
|
220
|
-
npx agents-relay init --repo OWNER/REPO --pr 12 --id
|
|
220
|
+
npx agents-relay init --repo OWNER/REPO --pr 12 --id 101 --title "Objective"`,
|
|
221
221
|
serve: `Run the dashboard and reconciliation service for a managed job.
|
|
222
222
|
|
|
223
223
|
Required: --file PATH for local demo mode, or --repo OWNER/REPO --pr NUMBER --id JOB_ID.
|
|
224
224
|
Common: --port PORT, --interval MS, --events nats (optional; reachable NATS is auto-detected), --thread-id ID (reviewer-thread override).
|
|
225
225
|
|
|
226
226
|
Example:
|
|
227
|
-
npx agents-relay serve --repo OWNER/REPO --pr 12 --id
|
|
227
|
+
npx agents-relay serve --repo OWNER/REPO --pr 12 --id 101`,
|
|
228
228
|
'agent-register': `Register an agent role and its execution endpoint.
|
|
229
229
|
|
|
230
230
|
Required for durable service mode: --repo OWNER/REPO --id JOB_ID plus --name NAME --responsibility TEXT --role ROLE --endpoint TARGET. Use --file only for explicit demo mode.
|
package/dist/cli-job-create.js
CHANGED
|
@@ -5,10 +5,12 @@ import { normalizeThreadId } from './reviewer-thread.js';
|
|
|
5
5
|
import { discoverLocalRelayStoreSnapshots, relayStoreDirectory } from './relay-store.js';
|
|
6
6
|
/**
|
|
7
7
|
* Ordinary Job creation is local and objective-oriented. Legacy PR metadata is
|
|
8
|
-
* handled by
|
|
8
|
+
* handled by adoptExistingPullRequestJob below so the normal contract cannot accidentally
|
|
9
9
|
* create a second external lifecycle.
|
|
10
10
|
*/
|
|
11
11
|
export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAuthors, options) {
|
|
12
|
+
if (options.id && !/^[1-9][0-9]*$/.test(options.id))
|
|
13
|
+
throw new Error('Job ID must be a positive decimal integer');
|
|
12
14
|
const directory = options.storeDirectory ?? relayStoreDirectory();
|
|
13
15
|
const knownJobs = (await discoverLocalRelayStoreSnapshots(directory)).flatMap(snapshot => snapshot.jobs);
|
|
14
16
|
const id = options.id || allocateEntityId(directory, repositoryName, 'job', options.idempotencyKey, new Set(knownJobs.map(job => job.id)));
|
|
@@ -88,27 +90,33 @@ export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAu
|
|
|
88
90
|
local.relay.close();
|
|
89
91
|
}
|
|
90
92
|
}
|
|
91
|
-
/**
|
|
92
|
-
export async function
|
|
93
|
-
if (!
|
|
94
|
-
throw new Error('job adopt requires
|
|
93
|
+
/** Import an existing PR without letting external labels choose the durable numeric Job ID. */
|
|
94
|
+
export async function adoptExistingPullRequestJob(_client, repositoryName, _trustedAuthors, options) {
|
|
95
|
+
if (!Number.isInteger(options.prNumber) || options.prNumber <= 0)
|
|
96
|
+
throw new Error('job adopt requires a positive --pr');
|
|
97
|
+
if (options.id && !/^[1-9][0-9]*$/.test(options.id))
|
|
98
|
+
throw new Error('Job ID must be a positive decimal integer');
|
|
95
99
|
const directory = options.storeDirectory ?? relayStoreDirectory();
|
|
96
|
-
const
|
|
100
|
+
const knownJobs = (await discoverLocalRelayStoreSnapshots(directory)).flatMap(snapshot => snapshot.jobs);
|
|
101
|
+
const matches = options.id ? knownJobs.filter(job => job.id === options.id) : knownJobs.filter(job => job.repository === repositoryName && job.prNumber === options.prNumber);
|
|
97
102
|
if (matches.length > 1)
|
|
98
|
-
throw new Error(
|
|
103
|
+
throw new Error('Ambiguous existing immutable Job or PR binding');
|
|
99
104
|
if (matches.length === 1) {
|
|
100
105
|
const existing = matches[0];
|
|
101
106
|
if (existing.repository !== repositoryName || existing.prNumber !== options.prNumber)
|
|
102
107
|
throw new Error(`Job ID ${options.id} is already bound to another repository or PR`);
|
|
103
108
|
return { job: existing, pr: { number: existing.prNumber, title: existing.title, body: existing.objective ?? '', state: 'OPEN', head: '', base: 'main' } };
|
|
104
109
|
}
|
|
105
|
-
|
|
110
|
+
if (options.id)
|
|
111
|
+
throw new Error(`Unknown numeric Job ID ${options.id}; new PR adoptions allocate IDs automatically`);
|
|
112
|
+
const id = allocateEntityId(directory, repositoryName, 'job', `adopt:pr:${options.prNumber}`, new Set(knownJobs.map(job => job.id)));
|
|
113
|
+
const local = openLocalManagedStore(repositoryName, options.prNumber, { directory, jobId: id, noGitHub: true });
|
|
106
114
|
try {
|
|
107
|
-
const existing = await local.relay.
|
|
115
|
+
const existing = (await local.relay.listJobs()).find(job => job.repository === repositoryName && job.prNumber === options.prNumber);
|
|
108
116
|
if (existing)
|
|
109
|
-
return { job: existing, pr: { number: existing.prNumber, title: existing.title, body: existing.objective ?? '', state: 'OPEN', head: '', base: '' } };
|
|
117
|
+
return { job: existing, pr: { number: existing.prNumber, title: existing.title, body: existing.objective ?? '', state: 'OPEN', head: '', base: 'main' } };
|
|
110
118
|
const now = new Date().toISOString();
|
|
111
|
-
const job = createJobEntity({ id
|
|
119
|
+
const job = createJobEntity({ id, title: options.title ?? id, objective: options.objective, projectPath: options.projectPath,
|
|
112
120
|
description: options.objective, priority: 'P2', executionMode: 'fixed', autoMerge: true, reviewEnabled: true,
|
|
113
121
|
prNumber: options.prNumber, repository: repositoryName, continuation: null, createdAt: now, updatedAt: now, tasks: [] });
|
|
114
122
|
const created = local.store.createJobIfAbsent ? await local.store.createJobIfAbsent(job) : (await local.store.saveJob(job), job);
|
package/dist/cli.js
CHANGED
|
@@ -384,11 +384,11 @@ async function runJobCommand(action, args) {
|
|
|
384
384
|
if (action === 'adopt') {
|
|
385
385
|
const adoptId = arg(args, '--id');
|
|
386
386
|
const adoptPr = Number(arg(args, '--pr'));
|
|
387
|
-
if (!
|
|
388
|
-
throw new Error('job adopt requires
|
|
387
|
+
if (!Number.isInteger(adoptPr) || adoptPr <= 0)
|
|
388
|
+
throw new Error('job adopt requires a positive --pr');
|
|
389
389
|
const objective = await textOption(args, '--objective', '--objective-file');
|
|
390
390
|
const result = await relayControlCommand(args, { command: 'job.adopt', repository: repo, options: {
|
|
391
|
-
id: adoptId, prNumber: adoptPr, title: arg(args, '--title') || undefined, objective: objective || undefined, projectPath: arg(args, '--project-path') || undefined,
|
|
391
|
+
id: adoptId || undefined, prNumber: adoptPr, title: arg(args, '--title') || undefined, objective: objective || undefined, projectPath: arg(args, '--project-path') || undefined,
|
|
392
392
|
} });
|
|
393
393
|
console.log(JSON.stringify(result, null, 2));
|
|
394
394
|
return;
|
|
@@ -1,34 +1,25 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
2
|
import { mkdirSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
|
|
5
|
-
const [owner, repo] = repository.split('/');
|
|
6
|
-
if (!owner || !repo || !/^[\w.-]+$/.test(owner) || !/^[\w.-]+$/.test(repo) || owner === '..' || repo === '..')
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
function validateRepository(repository) {
|
|
5
|
+
const [owner, repo, extra] = repository.split('/');
|
|
6
|
+
if (!owner || !repo || extra || !/^[\w.-]+$/.test(owner) || !/^[\w.-]+$/.test(repo) || owner === '..' || repo === '..')
|
|
7
7
|
throw new Error('Invalid repository');
|
|
8
|
-
return `${kind === 'job' ? 'j' : 't'}-${owner.length}-${owner}-${repo}-`;
|
|
9
8
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
return true;
|
|
14
|
-
const legacyPrefix = generatedEntityPrefix(repository, kind);
|
|
15
|
-
return id.startsWith(legacyPrefix) && /^[0-9]+$/.test(id.slice(legacyPrefix.length));
|
|
16
|
-
}
|
|
17
|
-
if (/^[1-9][0-9]*$/.test(id))
|
|
18
|
-
return true;
|
|
19
|
-
const prefix = generatedEntityPrefix(repository, kind);
|
|
20
|
-
return id.startsWith(prefix) && /^[0-9]+$/.test(id.slice(prefix.length));
|
|
9
|
+
/** A generated ID is always a positive decimal. Historic nonnumeric Task IDs remain ordinary Task identifiers. */
|
|
10
|
+
export function isGeneratedEntityId(id, _repository, _kind) {
|
|
11
|
+
return /^[1-9][0-9]*$/.test(id);
|
|
21
12
|
}
|
|
22
|
-
/** Job and Task IDs each use their own store-global sequence. SQLite serializes allocation
|
|
13
|
+
/** Job and Task IDs each use their own store-global sequence. SQLite serializes allocation. */
|
|
23
14
|
export function allocateEntityId(directory, repository, kind, requestKey, occupied = new Set()) {
|
|
24
|
-
|
|
15
|
+
validateRepository(repository);
|
|
25
16
|
const prefix = '';
|
|
26
17
|
const scope = '*';
|
|
27
18
|
const scopedRequestKey = requestKey ? `${repository}:${requestKey}` : undefined;
|
|
28
19
|
if (requestKey !== undefined && (!requestKey || requestKey.length > 512))
|
|
29
20
|
throw new Error('Invalid idempotency key');
|
|
30
21
|
mkdirSync(directory, { recursive: true });
|
|
31
|
-
const db = new DatabaseSync(join(directory, 'entity-id-sequences.sqlite'));
|
|
22
|
+
const db = new DatabaseSync(process.env.AGENTS_RELAY_SQLITE_MODE === 'unified' ? join(dirname(directory), 'relay.sqlite') : join(directory, 'entity-id-sequences.sqlite'));
|
|
32
23
|
try {
|
|
33
24
|
db.exec('PRAGMA journal_mode=WAL; PRAGMA busy_timeout=10000;');
|
|
34
25
|
db.exec('CREATE TABLE IF NOT EXISTS sequences (repository TEXT NOT NULL, kind TEXT NOT NULL, value INTEGER NOT NULL, PRIMARY KEY(repository,kind)); CREATE TABLE IF NOT EXISTS idempotency (repository TEXT NOT NULL, kind TEXT NOT NULL, request_key TEXT NOT NULL, entity_id TEXT NOT NULL, PRIMARY KEY(repository,kind,request_key));');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { GitHubStore, InMemoryStore } from './store.js';
|
|
2
2
|
import { LocalAuthorityDurableStore } from './local-authority-store.js';
|
|
3
|
-
import { discoverLocalRelayStoreSnapshots, SQLiteRelayStore } from './relay-store.js';
|
|
3
|
+
import { discoverLocalRelayStoreSnapshots, unifiedRelayStoreEnabled, SQLiteRelayStore } from './relay-store.js';
|
|
4
4
|
/** Resolve exactly one SQLite-backed Job from its immutable ID, independent of PR binding. */
|
|
5
5
|
export function createJobIdRegistryResolver(directory, openStores, options = {}) {
|
|
6
6
|
return async (jobId) => {
|
|
@@ -12,10 +12,11 @@ export function createJobIdRegistryResolver(directory, openStores, options = {})
|
|
|
12
12
|
if (!selected)
|
|
13
13
|
return null;
|
|
14
14
|
const { snapshot, job } = selected;
|
|
15
|
-
|
|
15
|
+
const scopedKey = unifiedRelayStoreEnabled() ? `${snapshot.path}#${job.id}` : snapshot.path;
|
|
16
|
+
let relay = openStores.get(scopedKey);
|
|
16
17
|
if (!relay) {
|
|
17
|
-
relay = new SQLiteRelayStore(snapshot.path);
|
|
18
|
-
openStores.set(
|
|
18
|
+
relay = new SQLiteRelayStore(snapshot.path, unifiedRelayStoreEnabled() ? job.id : undefined);
|
|
19
|
+
openStores.set(scopedKey, relay);
|
|
19
20
|
}
|
|
20
21
|
// SQLite remains lifecycle authority. A positive PR binding only enables
|
|
21
22
|
// the external collaboration adapter for projection/merge side effects.
|
|
@@ -2,18 +2,20 @@ import { GhApiClient } from './github-client.js';
|
|
|
2
2
|
import { DisabledGitHubClient, noGitHubMode } from './no-github.js';
|
|
3
3
|
import { GitHubStore } from './store.js';
|
|
4
4
|
import { LocalAuthorityDurableStore } from './local-authority-store.js';
|
|
5
|
-
import { relayStoreDirectory, relayStorePath, relayJobStorePath, SQLiteRelayStore } from './relay-store.js';
|
|
5
|
+
import { relayStoreDirectory, relayStorePath, relayJobStorePath, relayUnifiedStorePath, unifiedRelayStoreEnabled, resolveUnifiedJobScope, SQLiteRelayStore } from './relay-store.js';
|
|
6
6
|
/** Open the canonical V2 SQLite authority without making any GitHub request. */
|
|
7
7
|
export function openLocalManagedStore(repository, prNumber, options = {}) {
|
|
8
8
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || repository.includes('..') || !Number.isInteger(prNumber) || prNumber < 0 || (prNumber === 0 && !options.jobId)) {
|
|
9
9
|
throw new Error('Local managed store requires a repository and either a positive PR number or immutable jobId');
|
|
10
10
|
}
|
|
11
11
|
const directory = options.directory ?? relayStoreDirectory();
|
|
12
|
-
const
|
|
12
|
+
const unified = unifiedRelayStoreEnabled();
|
|
13
|
+
const scope = unified ? resolveUnifiedJobScope(directory, repository, prNumber, options.jobId) : undefined;
|
|
14
|
+
const relay = new SQLiteRelayStore(unified ? relayUnifiedStorePath(directory) : (prNumber === 0 ? relayJobStorePath(directory, options.jobId) : relayStorePath(directory, repository, prNumber)), scope);
|
|
13
15
|
const client = prNumber === 0 || options.noGitHub || noGitHubMode() ? new DisabledGitHubClient() : options.client ?? new GhApiClient();
|
|
14
16
|
const github = new GitHubStore(client, repository, prNumber, options.trustedAuthors ?? new Set());
|
|
15
17
|
return {
|
|
16
18
|
relay,
|
|
17
|
-
store: new LocalAuthorityDurableStore(relay, github, (relatedRepository, relatedPr) => openLocalManagedStore(relatedRepository, relatedPr, options).store, options.noGitHub || noGitHubMode()),
|
|
19
|
+
store: new LocalAuthorityDurableStore(relay, github, (relatedRepository, relatedPr) => openLocalManagedStore(relatedRepository, relatedPr, { ...options, jobId: undefined }).store, options.noGitHub || noGitHubMode()),
|
|
18
20
|
};
|
|
19
21
|
}
|
|
@@ -56,8 +56,14 @@ const OUTBOX_COLUMNS = 'id,idempotency_key,operation_key,coalesce_key,operation,
|
|
|
56
56
|
*/
|
|
57
57
|
export class ProjectionOutbox {
|
|
58
58
|
db;
|
|
59
|
-
|
|
59
|
+
scopeJobId;
|
|
60
|
+
constructor(db, scopeJobId) {
|
|
60
61
|
this.db = db;
|
|
62
|
+
this.scopeJobId = scopeJobId;
|
|
63
|
+
}
|
|
64
|
+
get scopeAnd() { return this.scopeJobId ? ' AND scope_job_id=?' : ''; }
|
|
65
|
+
bind(...params) {
|
|
66
|
+
return this.scopeJobId ? [...params, this.scopeJobId] : params;
|
|
61
67
|
}
|
|
62
68
|
static createTableSql(table = 'projection_outbox') {
|
|
63
69
|
return `CREATE TABLE IF NOT EXISTS ${table} (
|
|
@@ -146,7 +152,7 @@ export class ProjectionOutbox {
|
|
|
146
152
|
}
|
|
147
153
|
resetProcessing(now) {
|
|
148
154
|
const cutoff = new Date(Date.parse(now) - 120_000).toISOString();
|
|
149
|
-
this.db.prepare(
|
|
155
|
+
this.db.prepare(`UPDATE projection_outbox SET status='pending', updated_at=? WHERE status='processing' AND updated_at<=?${this.scopeAnd}`).run(...this.bind(now, cutoff));
|
|
150
156
|
}
|
|
151
157
|
enqueue(request, now) {
|
|
152
158
|
const operationKey = logicalOperationKey(request.idempotencyKey);
|
|
@@ -154,20 +160,32 @@ export class ProjectionOutbox {
|
|
|
154
160
|
if (request.coalesceKey) {
|
|
155
161
|
const removed = this.db.prepare(`
|
|
156
162
|
DELETE FROM projection_outbox
|
|
157
|
-
WHERE coalesce_key=? AND status='pending' AND operation_key
|
|
158
|
-
`).run(request.coalesceKey, operationKey);
|
|
163
|
+
WHERE coalesce_key=? AND status='pending' AND operation_key<>?${this.scopeAnd}
|
|
164
|
+
`).run(...this.bind(request.coalesceKey, operationKey));
|
|
159
165
|
coalesced = Number(removed.changes) > 0;
|
|
160
166
|
}
|
|
161
|
-
const existing = this.db.prepare(
|
|
162
|
-
.get(operationKey);
|
|
167
|
+
const existing = this.db.prepare(`SELECT status FROM projection_outbox WHERE operation_key=?${this.scopeAnd} ORDER BY id DESC LIMIT 1`)
|
|
168
|
+
.get(...this.bind(operationKey));
|
|
163
169
|
// A writer may be applying this key outside the transaction, or a deterministic
|
|
164
170
|
// conflict may already be recorded for it. Never rewrite that row's payload: its
|
|
165
171
|
// completion must only remove its own row, and a recorded conflict must stay the
|
|
166
172
|
// classification evidence until explicitly repaired. New mutations queue beside it.
|
|
167
|
-
const nextId = Number(this.db.prepare(
|
|
173
|
+
const nextId = Number(this.db.prepare(`SELECT COALESCE(MAX(id),0)+1 AS next_id FROM projection_outbox${this.scopeJobId ? ' WHERE scope_job_id=?' : ''}`).get(...(this.scopeJobId ? [this.scopeJobId] : [])).next_id ?? 1);
|
|
168
174
|
const collides = existing?.status === 'processing' || existing?.status === 'conflict' || existing?.status === 'dead';
|
|
169
175
|
const idempotencyKey = collides ? `${operationKey}:queued:${nextId}` : operationKey;
|
|
170
|
-
this.
|
|
176
|
+
const insertSql = this.scopeJobId ? `
|
|
177
|
+
INSERT INTO projection_outbox(
|
|
178
|
+
scope_job_id,id,idempotency_key,operation_key,coalesce_key,operation,payload_json,priority,status,
|
|
179
|
+
attempts,next_attempt_at,last_error,created_at,updated_at
|
|
180
|
+
) VALUES(?,?,?,?,?,?,?,?,'pending',0,NULL,NULL,?,?)
|
|
181
|
+
ON CONFLICT(scope_job_id,idempotency_key) DO UPDATE SET
|
|
182
|
+
operation_key=excluded.operation_key,
|
|
183
|
+
coalesce_key=excluded.coalesce_key,
|
|
184
|
+
operation=excluded.operation,
|
|
185
|
+
payload_json=excluded.payload_json,
|
|
186
|
+
priority=MAX(projection_outbox.priority,excluded.priority),
|
|
187
|
+
status='pending', next_attempt_at=NULL, last_error=NULL, updated_at=excluded.updated_at
|
|
188
|
+
` : `
|
|
171
189
|
INSERT INTO projection_outbox(
|
|
172
190
|
idempotency_key,operation_key,coalesce_key,operation,payload_json,priority,status,
|
|
173
191
|
attempts,next_attempt_at,last_error,created_at,updated_at
|
|
@@ -178,11 +196,11 @@ export class ProjectionOutbox {
|
|
|
178
196
|
operation=excluded.operation,
|
|
179
197
|
payload_json=excluded.payload_json,
|
|
180
198
|
priority=MAX(projection_outbox.priority,excluded.priority),
|
|
181
|
-
status='pending',
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
199
|
+
status='pending', next_attempt_at=NULL, last_error=NULL, updated_at=excluded.updated_at
|
|
200
|
+
`;
|
|
201
|
+
const params = [idempotencyKey, operationKey, request.coalesceKey ?? null,
|
|
202
|
+
request.operation, JSON.stringify(request.payload), request.priority ?? 0, now, now];
|
|
203
|
+
this.db.prepare(insertSql).run(...(this.scopeJobId ? [this.scopeJobId, nextId, ...params] : params));
|
|
186
204
|
return coalesced;
|
|
187
205
|
}
|
|
188
206
|
claimDue(limit, now) {
|
|
@@ -193,14 +211,14 @@ export class ProjectionOutbox {
|
|
|
193
211
|
const rows = this.db.prepare(`
|
|
194
212
|
SELECT ${OUTBOX_COLUMNS}
|
|
195
213
|
FROM projection_outbox
|
|
196
|
-
WHERE status='pending' AND (next_attempt_at IS NULL OR next_attempt_at<=?)
|
|
214
|
+
WHERE status='pending' AND (next_attempt_at IS NULL OR next_attempt_at<=?)${this.scopeAnd}
|
|
197
215
|
ORDER BY priority DESC, id ASC
|
|
198
216
|
LIMIT ?
|
|
199
|
-
`).all(observedAt, Math.max(1, Math.floor(limit)));
|
|
200
|
-
const mark = this.db.prepare(
|
|
217
|
+
`).all(observedAt, ...(this.scopeJobId ? [this.scopeJobId] : []), Math.max(1, Math.floor(limit)));
|
|
218
|
+
const mark = this.db.prepare(`UPDATE projection_outbox SET status='processing', updated_at=? WHERE id=? AND status='pending'${this.scopeAnd}`);
|
|
201
219
|
const claimed = [];
|
|
202
220
|
for (const row of rows) {
|
|
203
|
-
const result = mark.run(observedAt, row.id);
|
|
221
|
+
const result = mark.run(...this.bind(observedAt, row.id));
|
|
204
222
|
if (Number(result.changes) === 1)
|
|
205
223
|
claimed.push({ ...row, updated_at: observedAt });
|
|
206
224
|
}
|
|
@@ -213,13 +231,13 @@ export class ProjectionOutbox {
|
|
|
213
231
|
}
|
|
214
232
|
}
|
|
215
233
|
complete(id) {
|
|
216
|
-
this.db.prepare(
|
|
234
|
+
this.db.prepare(`DELETE FROM projection_outbox WHERE id=?${this.scopeAnd}`).run(...this.bind(id));
|
|
217
235
|
}
|
|
218
236
|
completeWithReceipt(id, operationKey, status, attempts, detail, now) {
|
|
219
237
|
this.db.exec('BEGIN IMMEDIATE');
|
|
220
238
|
try {
|
|
221
239
|
this.recordReceipt(operationKey, status, attempts, detail, now);
|
|
222
|
-
this.db.prepare(
|
|
240
|
+
this.db.prepare(`DELETE FROM projection_outbox WHERE id=?${this.scopeAnd}`).run(...this.bind(id));
|
|
223
241
|
this.db.exec('COMMIT');
|
|
224
242
|
}
|
|
225
243
|
catch (error) {
|
|
@@ -231,8 +249,8 @@ export class ProjectionOutbox {
|
|
|
231
249
|
this.db.exec('BEGIN IMMEDIATE');
|
|
232
250
|
try {
|
|
233
251
|
this.db.prepare(`UPDATE projection_outbox
|
|
234
|
-
SET status='pending', attempts=?, next_attempt_at=?, last_error=?, updated_at=? WHERE id
|
|
235
|
-
.run(attempts, nextAttemptAt.toISOString(), error, now, id);
|
|
252
|
+
SET status='pending', attempts=?, next_attempt_at=?, last_error=?, updated_at=? WHERE id=?${this.scopeAnd}`)
|
|
253
|
+
.run(...this.bind(attempts, nextAttemptAt.toISOString(), error, now, id));
|
|
236
254
|
this.recordReceipt(operationKey, 'failed', attempts, detail, now);
|
|
237
255
|
this.db.exec('COMMIT');
|
|
238
256
|
}
|
|
@@ -245,20 +263,27 @@ export class ProjectionOutbox {
|
|
|
245
263
|
this.db.prepare(`
|
|
246
264
|
UPDATE projection_outbox
|
|
247
265
|
SET status='pending', attempts=attempts+1, next_attempt_at=?, last_error=?, updated_at=?
|
|
248
|
-
WHERE id
|
|
249
|
-
`).run(nextAttemptAt.toISOString(), error, new Date().toISOString(), id);
|
|
266
|
+
WHERE id=?${this.scopeAnd}
|
|
267
|
+
`).run(...this.bind(nextAttemptAt.toISOString(), error, new Date().toISOString(), id));
|
|
250
268
|
}
|
|
251
269
|
deadLetter(id, error) {
|
|
252
270
|
this.db.prepare(`
|
|
253
271
|
UPDATE projection_outbox
|
|
254
272
|
SET status='dead', attempts=attempts+1, next_attempt_at=NULL, last_error=?, updated_at=?
|
|
255
|
-
WHERE id
|
|
256
|
-
`).run(error, new Date().toISOString(), id);
|
|
273
|
+
WHERE id=?${this.scopeAnd}
|
|
274
|
+
`).run(...this.bind(error, new Date().toISOString(), id));
|
|
257
275
|
}
|
|
258
276
|
recordReceipt(operationKey, status, attempts, detail, now) {
|
|
259
|
-
this.
|
|
260
|
-
|
|
261
|
-
|
|
277
|
+
if (this.scopeJobId) {
|
|
278
|
+
this.db.prepare(`INSERT INTO projection_receipt(scope_job_id,operation_key,status,attempts,detail_json,recorded_at)
|
|
279
|
+
VALUES(?,?,?,?,?,?) ON CONFLICT(scope_job_id,operation_key) DO UPDATE SET status=excluded.status, attempts=excluded.attempts,
|
|
280
|
+
detail_json=excluded.detail_json, recorded_at=excluded.recorded_at`).run(this.scopeJobId, operationKey, status, attempts, JSON.stringify(detail ?? null), now);
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
this.db.prepare(`INSERT INTO projection_receipt(operation_key,status,attempts,detail_json,recorded_at)
|
|
284
|
+
VALUES(?,?,?,?,?) ON CONFLICT(operation_key) DO UPDATE SET status=excluded.status, attempts=excluded.attempts,
|
|
285
|
+
detail_json=excluded.detail_json, recorded_at=excluded.recorded_at`).run(operationKey, status, attempts, JSON.stringify(detail ?? null), now);
|
|
286
|
+
}
|
|
262
287
|
}
|
|
263
288
|
recordConflict(id, diagnostic) {
|
|
264
289
|
const evidence = JSON.stringify({
|
|
@@ -274,19 +299,19 @@ export class ProjectionOutbox {
|
|
|
274
299
|
SET status='conflict', attempts=attempts+1, next_attempt_at=NULL, last_error=?,
|
|
275
300
|
conflict_definition_json=?, conflict_evidence_json=?, conflict_detected_at=?,
|
|
276
301
|
conflict_disposition='awaiting_repair', conflict_resolution_json=NULL, updated_at=?
|
|
277
|
-
WHERE id=? AND status NOT IN ('conflict','dead')
|
|
278
|
-
`).run(diagnostic.message, JSON.stringify(diagnostic.localDefinition), evidence, diagnostic.detectedAt, diagnostic.detectedAt, id);
|
|
302
|
+
WHERE id=? AND status NOT IN ('conflict','dead')${this.scopeAnd}
|
|
303
|
+
`).run(...this.bind(diagnostic.message, JSON.stringify(diagnostic.localDefinition), evidence, diagnostic.detectedAt, diagnostic.detectedAt, id));
|
|
279
304
|
}
|
|
280
305
|
listConflicts() {
|
|
281
306
|
const rows = this.db.prepare(`
|
|
282
|
-
SELECT ${OUTBOX_COLUMNS} FROM projection_outbox WHERE status='conflict' ORDER BY id ASC
|
|
283
|
-
`).all();
|
|
307
|
+
SELECT ${OUTBOX_COLUMNS} FROM projection_outbox WHERE status='conflict'${this.scopeAnd} ORDER BY id ASC
|
|
308
|
+
`).all(...(this.scopeJobId ? [this.scopeJobId] : []));
|
|
284
309
|
return rows.map(rowToConflict);
|
|
285
310
|
}
|
|
286
311
|
/** Explicit repair: requeue the original projection, or discard the blocked local mutation for a fresh one. */
|
|
287
312
|
resolveConflict(id, resolution) {
|
|
288
313
|
const now = new Date().toISOString();
|
|
289
|
-
const record = this.db.prepare(
|
|
314
|
+
const record = this.db.prepare(`SELECT status FROM projection_outbox WHERE id=?${this.scopeAnd}`).get(...this.bind(id));
|
|
290
315
|
if (!record || record.status !== 'conflict')
|
|
291
316
|
return false;
|
|
292
317
|
const resolutionJson = JSON.stringify({ action: resolution.action, resolvedAt: now, ...(resolution.note ? { note: resolution.note } : {}) });
|
|
@@ -295,11 +320,11 @@ export class ProjectionOutbox {
|
|
|
295
320
|
UPDATE projection_outbox
|
|
296
321
|
SET status='pending', attempts=0, next_attempt_at=NULL, conflict_disposition='requeued',
|
|
297
322
|
conflict_resolution_json=?, updated_at=?
|
|
298
|
-
WHERE id=? AND status='conflict'
|
|
299
|
-
`).run(resolutionJson, now, id);
|
|
323
|
+
WHERE id=? AND status='conflict'${this.scopeAnd}
|
|
324
|
+
`).run(...this.bind(resolutionJson, now, id));
|
|
300
325
|
}
|
|
301
326
|
else {
|
|
302
|
-
this.db.prepare(
|
|
327
|
+
this.db.prepare(`DELETE FROM projection_outbox WHERE id=? AND status=?${this.scopeAnd}`).run(...this.bind(id, 'conflict'));
|
|
303
328
|
}
|
|
304
329
|
return true;
|
|
305
330
|
}
|
|
@@ -307,7 +332,7 @@ export class ProjectionOutbox {
|
|
|
307
332
|
pendingCount() { return this.count("status IN ('pending','processing')"); }
|
|
308
333
|
terminalCount() { return this.count("status='dead'"); }
|
|
309
334
|
count(where) {
|
|
310
|
-
const row = this.db.prepare(`SELECT COUNT(*) AS count FROM projection_outbox WHERE ${where}`).get();
|
|
335
|
+
const row = this.db.prepare(`SELECT COUNT(*) AS count FROM projection_outbox WHERE ${where}${this.scopeAnd}`).get(...(this.scopeJobId ? [this.scopeJobId] : []));
|
|
311
336
|
return Number(row?.count ?? 0);
|
|
312
337
|
}
|
|
313
338
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { readdir } from 'node:fs/promises';
|
|
4
|
+
import { normalizeLegacyJobPrNumber } from './job-pr-number-migration.js';
|
|
5
|
+
/** Unified mode must be opted into only after the complete offline migration passes. */
|
|
6
|
+
export function unifiedRelayStoreEnabled(env = process.env) {
|
|
7
|
+
return env.AGENTS_RELAY_SQLITE_MODE === 'unified';
|
|
8
|
+
}
|
|
9
|
+
export function relayUnifiedStorePath(directory) { return join(dirname(directory), 'relay.sqlite'); }
|
|
10
|
+
/** Resolve one Job scope by repository and PR, refusing ambiguous matches. */
|
|
11
|
+
export function resolveUnifiedJobScope(directory, repository, prNumber, hintId) {
|
|
12
|
+
const db = new DatabaseSync(relayUnifiedStorePath(directory), { readOnly: true });
|
|
13
|
+
try {
|
|
14
|
+
if (hintId) {
|
|
15
|
+
const binding = db.prepare('SELECT repository,pr_number FROM source_registry WHERE job_id=?').get(hintId);
|
|
16
|
+
if (binding && (binding.repository !== repository || (prNumber > 0 && binding.pr_number !== prNumber)))
|
|
17
|
+
throw new Error(`Job ${hintId} is bound to another repository or PR`);
|
|
18
|
+
return hintId;
|
|
19
|
+
}
|
|
20
|
+
const rows = db.prepare('SELECT job_id FROM source_registry WHERE repository=? AND pr_number=?').all(repository, prNumber);
|
|
21
|
+
if (rows.length !== 1)
|
|
22
|
+
throw new Error(`Unified Job scope is ${rows.length ? 'ambiguous' : 'missing'} for ${repository} PR ${prNumber}; supply immutable Job ID`);
|
|
23
|
+
return rows[0].job_id;
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
db.close();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Canonical on-disk Relay Store location for one managed GitHub pull request. */
|
|
30
|
+
export function relayStorePath(directory, repository, prNumber) {
|
|
31
|
+
return directory + '/' + `${repository.replace('/', '_')}-${prNumber}.db`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* SQLite is the only job registry. Inventory queries must be strictly read-only:
|
|
35
|
+
* opening a writable RelayStore here would reset in-flight projection claims
|
|
36
|
+
* on every dashboard refresh and could replay external merge side effects.
|
|
37
|
+
*/
|
|
38
|
+
export async function discoverLocalRelayStoreSnapshots(directory) {
|
|
39
|
+
if (unifiedRelayStoreEnabled()) {
|
|
40
|
+
const path = relayUnifiedStorePath(directory);
|
|
41
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
42
|
+
try {
|
|
43
|
+
const snapshots = [];
|
|
44
|
+
const rows = db.prepare('SELECT json FROM job_snapshot ORDER BY CAST(job_id AS INTEGER)').all();
|
|
45
|
+
const allPublications = Object.fromEntries(db.prepare('SELECT job_id,json FROM publication_state').all().map(row => [row.job_id, JSON.parse(row.json)]));
|
|
46
|
+
for (const row of rows) {
|
|
47
|
+
const job = normalizeLegacyJobPrNumber(JSON.parse(row.json));
|
|
48
|
+
const fact = db.prepare('SELECT state,head_ref,head_sha,observed_at FROM pull_request_facts WHERE scope_job_id=? AND id=1').get(job.id);
|
|
49
|
+
const facts = fact ? { state: fact.state, head: fact.head_ref && fact.head_sha ? { ref: fact.head_ref, sha: fact.head_sha } : null, observedAt: fact.observed_at } : null;
|
|
50
|
+
const publications = {};
|
|
51
|
+
if (allPublications[job.id])
|
|
52
|
+
publications[job.id] = allPublications[job.id];
|
|
53
|
+
snapshots.push({ path, jobs: [job], facts, publications });
|
|
54
|
+
}
|
|
55
|
+
return snapshots;
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
db.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let names;
|
|
62
|
+
try {
|
|
63
|
+
names = await readdir(directory);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
const snapshots = [];
|
|
69
|
+
for (const name of names.filter(item => item.endsWith('.db')).sort()) {
|
|
70
|
+
const path = directory + '/' + name;
|
|
71
|
+
let db;
|
|
72
|
+
try {
|
|
73
|
+
db = new DatabaseSync(path, { readOnly: true });
|
|
74
|
+
const rows = db.prepare('SELECT json FROM job_snapshot ORDER BY job_id').all();
|
|
75
|
+
const jobs = rows.map(row => normalizeLegacyJobPrNumber(JSON.parse(row.json)));
|
|
76
|
+
const row = db.prepare('SELECT state,head_ref,head_sha,observed_at FROM pull_request_facts WHERE id=1').get();
|
|
77
|
+
const facts = row ? {
|
|
78
|
+
state: row.state,
|
|
79
|
+
head: row.head_ref && row.head_sha ? { ref: row.head_ref, sha: row.head_sha } : null,
|
|
80
|
+
observedAt: row.observed_at,
|
|
81
|
+
} : null;
|
|
82
|
+
const publications = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='publication_state'").get() ? Object.fromEntries(db.prepare('SELECT job_id,json FROM publication_state').all().map(item => [item.job_id, JSON.parse(item.json)])) : {};
|
|
83
|
+
snapshots.push({ path, jobs, facts, publications });
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Ignore unrelated/corrupt files without allowing one damaged store to block recovery.
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
db?.close();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return snapshots;
|
|
93
|
+
}
|
package/dist/relay-store.js
CHANGED
|
@@ -2,10 +2,11 @@ import { preserveTaskComments } from './task-comments.js';
|
|
|
2
2
|
import { DatabaseSync } from 'node:sqlite';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
4
|
import { mkdirSync } from 'node:fs';
|
|
5
|
-
import { readdir } from 'node:fs/promises';
|
|
6
5
|
import { recordPublicationInDatabase, loadPublicationFromDatabase, listPublicationEventsFromDatabase } from './publication-store.js';
|
|
7
6
|
import { observability } from './observability.js';
|
|
8
7
|
import { createJobIfAbsent } from './relay-create.js';
|
|
8
|
+
import { ensureUnifiedJobRegistry, createUnifiedJobIfAbsent } from './unified-job-registry.js';
|
|
9
|
+
import { saveUnifiedPullRequestFacts } from './unified-pr-facts.js';
|
|
9
10
|
import { migrateLegacyJobPrNumbers, normalizeLegacyJobPrNumber } from './job-pr-number-migration.js';
|
|
10
11
|
import { hostInvocationTablesSql, recordHostInvocationEvent, hasHostInvocationDecision, recordHostInvocationDecision } from './host-invocation-store.js';
|
|
11
12
|
import { ProjectionOutbox, SCHEMA_VERSION, logicalOperationKey, } from './projection-outbox.js';
|
|
@@ -44,18 +45,30 @@ function monotonicJobTimestamp(previous, candidate = new Date().toISOString()) {
|
|
|
44
45
|
}
|
|
45
46
|
export class SQLiteRelayStore {
|
|
46
47
|
path;
|
|
48
|
+
scopeJobId;
|
|
47
49
|
db;
|
|
48
50
|
outbox;
|
|
49
|
-
constructor(path) {
|
|
51
|
+
constructor(path, scopeJobId) {
|
|
50
52
|
this.path = path;
|
|
53
|
+
this.scopeJobId = scopeJobId;
|
|
51
54
|
if (path !== ':memory:')
|
|
52
55
|
mkdirSync(dirname(path), { recursive: true });
|
|
53
56
|
this.db = new DatabaseSync(path);
|
|
54
57
|
this.db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;');
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
if (scopeJobId) {
|
|
59
|
+
const schema = this.db.prepare("SELECT value FROM migration_metadata WHERE key='schema'").get();
|
|
60
|
+
if (schema?.value !== 'single-sqlite-staging-v1')
|
|
61
|
+
throw new Error('Unified Relay schema is missing or unsupported');
|
|
62
|
+
}
|
|
63
|
+
else
|
|
64
|
+
this.migrate();
|
|
65
|
+
this.outbox = new ProjectionOutbox(this.db, scopeJobId);
|
|
57
66
|
this.outbox.resetProcessing(new Date().toISOString());
|
|
58
67
|
}
|
|
68
|
+
assertScope(jobId) {
|
|
69
|
+
if (this.scopeJobId && jobId !== this.scopeJobId)
|
|
70
|
+
throw new Error(`Wrong scoped Job ID ${jobId}; expected ${this.scopeJobId}`);
|
|
71
|
+
}
|
|
59
72
|
migrate() {
|
|
60
73
|
this.db.exec(`
|
|
61
74
|
CREATE TABLE IF NOT EXISTS relay_meta (
|
|
@@ -125,21 +138,38 @@ export class SQLiteRelayStore {
|
|
|
125
138
|
return listPublicationEventsFromDatabase(this.db, jobId);
|
|
126
139
|
}
|
|
127
140
|
async loadJob(jobId) {
|
|
141
|
+
this.assertScope(jobId);
|
|
128
142
|
const row = this.db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(jobId);
|
|
129
143
|
return row?.json ? normalizeLegacyJobPrNumber(JSON.parse(row.json)) : null;
|
|
130
144
|
}
|
|
131
145
|
async listJobs() {
|
|
132
|
-
const rows = this.db.prepare('SELECT json FROM job_snapshot ORDER BY job_id').all();
|
|
146
|
+
const rows = this.db.prepare(this.scopeJobId ? 'SELECT json FROM job_snapshot WHERE job_id=? ORDER BY job_id' : 'SELECT json FROM job_snapshot ORDER BY job_id').all(...(this.scopeJobId ? [this.scopeJobId] : []));
|
|
133
147
|
return rows.map(row => normalizeLegacyJobPrNumber(JSON.parse(row.json)));
|
|
134
148
|
}
|
|
135
149
|
async saveJob(job) {
|
|
136
|
-
|
|
150
|
+
if (!this.scopeJobId) {
|
|
151
|
+
this.writeJobSnapshot(job, new Date().toISOString());
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
155
|
+
try {
|
|
156
|
+
this.writeJobSnapshot(job, new Date().toISOString());
|
|
157
|
+
this.db.exec('COMMIT');
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
this.db.exec('ROLLBACK');
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
137
163
|
}
|
|
138
164
|
async createJobIfAbsent(job) {
|
|
139
|
-
|
|
165
|
+
this.assertScope(job.id);
|
|
166
|
+
return this.scopeJobId
|
|
167
|
+
? createUnifiedJobIfAbsent(this.db, job, now => this.writeJobSnapshot(job, now))
|
|
168
|
+
: createJobIfAbsent(this.db, job);
|
|
140
169
|
}
|
|
141
170
|
/** Atomic read/modify/write for local authoritative Task and Plan updates. */
|
|
142
171
|
async mutateJob(jobId, mutation) {
|
|
172
|
+
this.assertScope(jobId);
|
|
143
173
|
this.db.exec('BEGIN IMMEDIATE');
|
|
144
174
|
try {
|
|
145
175
|
const row = this.db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(jobId);
|
|
@@ -163,6 +193,7 @@ export class SQLiteRelayStore {
|
|
|
163
193
|
}
|
|
164
194
|
}
|
|
165
195
|
async ingestJobSnapshot(job) {
|
|
196
|
+
this.assertScope(job.id);
|
|
166
197
|
const now = new Date().toISOString();
|
|
167
198
|
this.db.exec('BEGIN IMMEDIATE');
|
|
168
199
|
try {
|
|
@@ -179,6 +210,7 @@ export class SQLiteRelayStore {
|
|
|
179
210
|
}
|
|
180
211
|
}
|
|
181
212
|
async saveJobWithProjection(job, request) {
|
|
213
|
+
this.assertScope(job.id);
|
|
182
214
|
const now = new Date().toISOString();
|
|
183
215
|
this.db.exec('BEGIN IMMEDIATE');
|
|
184
216
|
try {
|
|
@@ -200,26 +232,38 @@ export class SQLiteRelayStore {
|
|
|
200
232
|
}
|
|
201
233
|
}
|
|
202
234
|
writeJobSnapshot(job, now) {
|
|
235
|
+
if (this.scopeJobId)
|
|
236
|
+
ensureUnifiedJobRegistry(this.db, job, this.scopeJobId);
|
|
203
237
|
this.db.prepare(`
|
|
204
238
|
INSERT INTO job_snapshot(job_id,json,updated_at) VALUES(?,?,?)
|
|
205
239
|
ON CONFLICT(job_id) DO UPDATE SET json=excluded.json, updated_at=excluded.updated_at
|
|
206
240
|
`).run(job.id, JSON.stringify(job), now);
|
|
207
241
|
}
|
|
208
242
|
async loadAgents() {
|
|
209
|
-
const observed = this.
|
|
243
|
+
const observed = this.scopeJobId
|
|
244
|
+
? this.db.prepare("SELECT value FROM relay_meta WHERE scope_job_id=? AND key='agent_registry_observed_at'").get(this.scopeJobId)
|
|
245
|
+
: this.db.prepare("SELECT value FROM relay_meta WHERE key='agent_registry_observed_at'").get();
|
|
210
246
|
if (!observed?.value)
|
|
211
247
|
return null;
|
|
212
|
-
const rows = this.db.prepare('SELECT json FROM agent_snapshot ORDER BY agent_id').all();
|
|
248
|
+
const rows = this.db.prepare(this.scopeJobId ? 'SELECT json FROM agent_snapshot WHERE scope_job_id=? ORDER BY agent_id' : 'SELECT json FROM agent_snapshot ORDER BY agent_id').all(...(this.scopeJobId ? [this.scopeJobId] : []));
|
|
213
249
|
return rows.map(row => JSON.parse(row.json));
|
|
214
250
|
}
|
|
215
251
|
async saveAgents(agents) {
|
|
216
252
|
const now = new Date().toISOString();
|
|
217
253
|
this.db.exec('BEGIN IMMEDIATE');
|
|
218
254
|
try {
|
|
219
|
-
this.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
255
|
+
if (this.scopeJobId) {
|
|
256
|
+
this.db.prepare('DELETE FROM agent_snapshot WHERE scope_job_id=?').run(this.scopeJobId);
|
|
257
|
+
const insert = this.db.prepare('INSERT INTO agent_snapshot(scope_job_id,agent_id,json,updated_at) VALUES(?,?,?,?)');
|
|
258
|
+
for (const agent of agents)
|
|
259
|
+
insert.run(this.scopeJobId, agent.id, JSON.stringify(agent), now);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
this.db.prepare('DELETE FROM agent_snapshot').run();
|
|
263
|
+
const insert = this.db.prepare('INSERT INTO agent_snapshot(agent_id,json,updated_at) VALUES(?,?,?)');
|
|
264
|
+
for (const agent of agents)
|
|
265
|
+
insert.run(agent.id, JSON.stringify(agent), now);
|
|
266
|
+
}
|
|
223
267
|
this.markAgentRegistryObserved(now);
|
|
224
268
|
this.db.exec('COMMIT');
|
|
225
269
|
}
|
|
@@ -256,19 +300,29 @@ export class SQLiteRelayStore {
|
|
|
256
300
|
}
|
|
257
301
|
}
|
|
258
302
|
writeAgentSnapshot(agent, now) {
|
|
259
|
-
this.
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
303
|
+
if (this.scopeJobId) {
|
|
304
|
+
this.db.prepare(`INSERT INTO agent_snapshot(scope_job_id,agent_id,json,updated_at) VALUES(?,?,?,?)
|
|
305
|
+
ON CONFLICT(scope_job_id,agent_id) DO UPDATE SET json=excluded.json, updated_at=excluded.updated_at`)
|
|
306
|
+
.run(this.scopeJobId, agent.id, JSON.stringify(agent), now);
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
this.db.prepare(`INSERT INTO agent_snapshot(agent_id,json,updated_at) VALUES(?,?,?)
|
|
310
|
+
ON CONFLICT(agent_id) DO UPDATE SET json=excluded.json, updated_at=excluded.updated_at`)
|
|
311
|
+
.run(agent.id, JSON.stringify(agent), now);
|
|
312
|
+
}
|
|
263
313
|
}
|
|
264
314
|
markAgentRegistryObserved(now) {
|
|
265
|
-
this.
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
315
|
+
if (this.scopeJobId) {
|
|
316
|
+
this.db.prepare(`INSERT INTO relay_meta(scope_job_id,key,value) VALUES(?,'agent_registry_observed_at',?)
|
|
317
|
+
ON CONFLICT(scope_job_id,key) DO UPDATE SET value=excluded.value`).run(this.scopeJobId, now);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
this.db.prepare(`INSERT INTO relay_meta(key,value) VALUES('agent_registry_observed_at',?)
|
|
321
|
+
ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(now);
|
|
322
|
+
}
|
|
269
323
|
}
|
|
270
324
|
async loadPullRequestFacts() {
|
|
271
|
-
const row = this.db.prepare('SELECT state,head_ref,head_sha,observed_at FROM pull_request_facts WHERE id=1').get();
|
|
325
|
+
const row = this.db.prepare(this.scopeJobId ? 'SELECT state,head_ref,head_sha,observed_at FROM pull_request_facts WHERE scope_job_id=? AND id=1' : 'SELECT state,head_ref,head_sha,observed_at FROM pull_request_facts WHERE id=1').get(...(this.scopeJobId ? [this.scopeJobId] : []));
|
|
272
326
|
if (!row?.state || !row.observed_at)
|
|
273
327
|
return null;
|
|
274
328
|
return { state: row.state, head: row.head_ref && row.head_sha ? { ref: row.head_ref, sha: row.head_sha } : null, observedAt: row.observed_at };
|
|
@@ -277,7 +331,11 @@ export class SQLiteRelayStore {
|
|
|
277
331
|
// UNKNOWN means absence of confirmed external evidence; never persist it as a fact.
|
|
278
332
|
if (facts.state === 'UNKNOWN')
|
|
279
333
|
return;
|
|
280
|
-
this.
|
|
334
|
+
if (this.scopeJobId) {
|
|
335
|
+
saveUnifiedPullRequestFacts(this.db, this.scopeJobId, facts);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
this.db.prepare(`
|
|
281
339
|
INSERT INTO pull_request_facts(id,state,head_ref,head_sha,observed_at) VALUES(1,?,?,?,?)
|
|
282
340
|
ON CONFLICT(id) DO UPDATE SET state=excluded.state,head_ref=excluded.head_ref,head_sha=excluded.head_sha,observed_at=excluded.observed_at
|
|
283
341
|
WHERE excluded.observed_at >= pull_request_facts.observed_at
|
|
@@ -285,6 +343,7 @@ export class SQLiteRelayStore {
|
|
|
285
343
|
OR (pull_request_facts.state='CLOSED' AND excluded.state IN ('CLOSED','MERGED'))
|
|
286
344
|
OR (pull_request_facts.state='MERGED' AND excluded.state='MERGED'))
|
|
287
345
|
`).run(facts.state, facts.head?.ref ?? null, facts.head?.sha ?? null, facts.observedAt);
|
|
346
|
+
}
|
|
288
347
|
}
|
|
289
348
|
async recordExecutionProcess(record) {
|
|
290
349
|
const existing = await this.loadExecutionProcess(record.jobId, record.taskId, record.executionId);
|
|
@@ -316,9 +375,11 @@ export class SQLiteRelayStore {
|
|
|
316
375
|
return recordHostInvocationDecision(this.db, delivery, fingerprint, (job, at) => this.writeJobSnapshot(job, at));
|
|
317
376
|
}
|
|
318
377
|
async markEventProcessed(eventId, stream = null, sequence = null) {
|
|
319
|
-
const result = this.
|
|
320
|
-
|
|
321
|
-
|
|
378
|
+
const result = this.scopeJobId
|
|
379
|
+
? this.db.prepare('INSERT OR IGNORE INTO event_receipt(scope_job_id,event_id,stream,sequence,received_at) VALUES(?,?,?,?,?)')
|
|
380
|
+
.run(this.scopeJobId, eventId, stream, sequence, new Date().toISOString())
|
|
381
|
+
: this.db.prepare('INSERT OR IGNORE INTO event_receipt(event_id,stream,sequence,received_at) VALUES(?,?,?,?)')
|
|
382
|
+
.run(eventId, stream, sequence, new Date().toISOString());
|
|
322
383
|
return Number(result.changes) === 1;
|
|
323
384
|
}
|
|
324
385
|
enqueueProjectionInTransaction(request, now) {
|
|
@@ -354,7 +415,11 @@ export class SQLiteRelayStore {
|
|
|
354
415
|
const key = logicalOperationKey(request.idempotencyKey);
|
|
355
416
|
this.db.exec('BEGIN IMMEDIATE');
|
|
356
417
|
try {
|
|
357
|
-
const existing = this.
|
|
418
|
+
const existing = this.scopeJobId
|
|
419
|
+
? (this.db.prepare('SELECT id FROM projection_outbox WHERE scope_job_id=? AND operation_key=? LIMIT 1').get(this.scopeJobId, key)
|
|
420
|
+
?? this.db.prepare('SELECT operation_key FROM projection_receipt WHERE scope_job_id=? AND operation_key=?').get(this.scopeJobId, key))
|
|
421
|
+
: (this.db.prepare('SELECT id FROM projection_outbox WHERE operation_key=? LIMIT 1').get(key)
|
|
422
|
+
?? this.db.prepare('SELECT operation_key FROM projection_receipt WHERE operation_key=?').get(key));
|
|
358
423
|
if (existing) {
|
|
359
424
|
this.db.exec('COMMIT');
|
|
360
425
|
return false;
|
|
@@ -389,47 +454,5 @@ export class SQLiteRelayStore {
|
|
|
389
454
|
async recordProjectionReceipt(operationKey, status, attempts, detail) { this.outbox.recordReceipt(operationKey, status, attempts, detail, new Date().toISOString()); }
|
|
390
455
|
close() { this.db.close(); }
|
|
391
456
|
}
|
|
392
|
-
/** Canonical on-disk Relay Store location for one managed GitHub pull request. */
|
|
393
|
-
export function relayStorePath(directory, repository, prNumber) {
|
|
394
|
-
return directory + '/' + `${repository.replace('/', '_')}-${prNumber}.db`;
|
|
395
|
-
}
|
|
396
457
|
export { relayJobStorePath, relayStoreDirectory } from './local-job-path.js';
|
|
397
|
-
|
|
398
|
-
* SQLite is the only job registry. Inventory queries must be strictly read-only:
|
|
399
|
-
* opening a writable RelayStore here would reset in-flight projection claims
|
|
400
|
-
* on every dashboard refresh and could replay external merge side effects.
|
|
401
|
-
*/
|
|
402
|
-
export async function discoverLocalRelayStoreSnapshots(directory) {
|
|
403
|
-
let names;
|
|
404
|
-
try {
|
|
405
|
-
names = await readdir(directory);
|
|
406
|
-
}
|
|
407
|
-
catch {
|
|
408
|
-
return [];
|
|
409
|
-
}
|
|
410
|
-
const snapshots = [];
|
|
411
|
-
for (const name of names.filter(item => item.endsWith('.db')).sort()) {
|
|
412
|
-
const path = directory + '/' + name;
|
|
413
|
-
let db;
|
|
414
|
-
try {
|
|
415
|
-
db = new DatabaseSync(path, { readOnly: true });
|
|
416
|
-
const rows = db.prepare('SELECT json FROM job_snapshot ORDER BY job_id').all();
|
|
417
|
-
const jobs = rows.map(row => normalizeLegacyJobPrNumber(JSON.parse(row.json)));
|
|
418
|
-
const row = db.prepare('SELECT state,head_ref,head_sha,observed_at FROM pull_request_facts WHERE id=1').get();
|
|
419
|
-
const facts = row ? {
|
|
420
|
-
state: row.state,
|
|
421
|
-
head: row.head_ref && row.head_sha ? { ref: row.head_ref, sha: row.head_sha } : null,
|
|
422
|
-
observedAt: row.observed_at,
|
|
423
|
-
} : null;
|
|
424
|
-
const publications = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='publication_state'").get() ? Object.fromEntries(db.prepare('SELECT job_id,json FROM publication_state').all().map(item => [item.job_id, JSON.parse(item.json)])) : {};
|
|
425
|
-
snapshots.push({ path, jobs, facts, publications });
|
|
426
|
-
}
|
|
427
|
-
catch {
|
|
428
|
-
// Ignore unrelated/corrupt files without allowing one damaged store to block recovery.
|
|
429
|
-
}
|
|
430
|
-
finally {
|
|
431
|
-
db?.close();
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
return snapshots;
|
|
435
|
-
}
|
|
458
|
+
export { unifiedRelayStoreEnabled, relayUnifiedStorePath, resolveUnifiedJobScope, relayStorePath, discoverLocalRelayStoreSnapshots } from './relay-store-discovery.js';
|
package/dist/relayd-control.js
CHANGED
|
@@ -4,7 +4,7 @@ import { openLocalManagedStore } from './local-managed-store.js';
|
|
|
4
4
|
import { relayStoreDirectory } from './relay-store.js';
|
|
5
5
|
import { normalizeThreadId } from './reviewer-thread.js';
|
|
6
6
|
import { supersedeCancelledTask } from './task-actions.js';
|
|
7
|
-
import {
|
|
7
|
+
import { adoptExistingPullRequestJob } from './cli-job-create.js';
|
|
8
8
|
export function createWorkspaceControl(deps) {
|
|
9
9
|
const { client, trusted, noGitHub, repositories, refreshAll, requireLocalJobBinding, makeReconciler, concurrency, pool, bus, isEffectivelyBlocked, unblockAutonomousTasks, unblockTask, publishTaskUnblocked, dispatchTaskAction, registerBlockerDependency, discoverAgents, ensureManagedGitHubJob } = deps;
|
|
10
10
|
const workspaceControl = async (body) => {
|
|
@@ -69,10 +69,10 @@ export function createWorkspaceControl(deps) {
|
|
|
69
69
|
if (command === 'job.adopt') {
|
|
70
70
|
const repository = typeof body.repository === 'string' ? body.repository : '';
|
|
71
71
|
const options = (body.options && typeof body.options === 'object' ? body.options : {});
|
|
72
|
-
if (!repository || typeof options.
|
|
73
|
-
throw new Error('job.adopt requires repository
|
|
74
|
-
const result = await
|
|
75
|
-
id: options.id, title: typeof options.title === 'string' ? options.title : undefined,
|
|
72
|
+
if (!repository || typeof options.prNumber !== 'number')
|
|
73
|
+
throw new Error('job.adopt requires repository and prNumber');
|
|
74
|
+
const result = await adoptExistingPullRequestJob(client, repository, trusted, {
|
|
75
|
+
id: typeof options.id === 'string' ? options.id : undefined, title: typeof options.title === 'string' ? options.title : undefined,
|
|
76
76
|
objective: typeof options.objective === 'string' ? options.objective : undefined,
|
|
77
77
|
projectPath: typeof options.projectPath === 'string' ? options.projectPath : undefined,
|
|
78
78
|
prNumber: options.prNumber,
|
package/dist/relayd.js
CHANGED
|
@@ -4,7 +4,7 @@ import { once } from 'node:events';
|
|
|
4
4
|
import { spawnOwnedProcess } from './owned-process.js';
|
|
5
5
|
import { readFile } from 'node:fs/promises';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
|
-
import { join } from 'node:path';
|
|
7
|
+
import { join, basename } from 'node:path';
|
|
8
8
|
import { ChatGptAdapter, CodexAdapter, ensureChatGptBrowserWorkspace } from './adapters.js';
|
|
9
9
|
import { CodexThreadContinuation, CommandContinuation, WebhookContinuation } from './continuation.js';
|
|
10
10
|
import { serveDashboard } from './dashboard.js';
|
|
@@ -13,7 +13,7 @@ import { Reconciler } from './reconciler.js';
|
|
|
13
13
|
import { dashboardManagedJobs, GitHubStore, InMemoryStore, resolvePullRequest } from './store.js';
|
|
14
14
|
import { GitHubProjectionWriter } from './projected-store.js';
|
|
15
15
|
import { LocalAuthorityDurableStore } from './local-authority-store.js';
|
|
16
|
-
import { discoverLocalRelayStoreSnapshots, relayStoreDirectory, relayStorePath, SQLiteRelayStore } from './relay-store.js';
|
|
16
|
+
import { discoverLocalRelayStoreSnapshots, relayStoreDirectory, relayStorePath, relayUnifiedStorePath, unifiedRelayStoreEnabled, resolveUnifiedJobScope, SQLiteRelayStore } from './relay-store.js';
|
|
17
17
|
import { createJobIdRegistryResolver } from './local-job-registry.js';
|
|
18
18
|
import { projectAuthoritativeLocalMerge } from './local-merge-projection.js';
|
|
19
19
|
import { archiveDisplayPullRequest } from './github-pr-archive.js';
|
|
@@ -22,7 +22,6 @@ import { ProjectionFailureDeduplicator } from './projection-failure-dedupe.js';
|
|
|
22
22
|
import { authoritativeBranchPushArgs } from './github-branch-projection.js';
|
|
23
23
|
import { githubAuthContext } from './github-auth.js';
|
|
24
24
|
import { DisabledGitHubClient, noGitHubMode } from './no-github.js';
|
|
25
|
-
import { basename } from 'node:path';
|
|
26
25
|
import { dashboardJobAction, isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
|
|
27
26
|
import { eventBusResolutionMessage, publishTaskUnblocked, resolveEventBus } from './cli-support.js';
|
|
28
27
|
import { eventFor } from './events.js';
|
|
@@ -117,7 +116,8 @@ export async function runDaemon(argv) {
|
|
|
117
116
|
const key = `${repository}#${prNumber}`;
|
|
118
117
|
let relay = relayStores.get(key);
|
|
119
118
|
if (!relay) {
|
|
120
|
-
|
|
119
|
+
const scope = unifiedRelayStoreEnabled() ? resolveUnifiedJobScope(relayStoreDirectory(), repository, prNumber) : undefined;
|
|
120
|
+
relay = new SQLiteRelayStore(scope ? relayUnifiedStorePath(relayStoreDirectory()) : relayStorePath(relayStoreDirectory(), repository, prNumber), scope);
|
|
121
121
|
relayStores.set(key, relay);
|
|
122
122
|
}
|
|
123
123
|
const github = new GitHubStore(client, repository, prNumber, trusted, seed);
|
|
@@ -127,7 +127,6 @@ export async function runDaemon(argv) {
|
|
|
127
127
|
projectionWriters.set(key, projected.projection);
|
|
128
128
|
return projected;
|
|
129
129
|
};
|
|
130
|
-
// SQLite inventory, not GitHub discovery, defines which repositories exist.
|
|
131
130
|
const existingInventory = await discoverLocalRelayStoreSnapshots(relayStoreDirectory());
|
|
132
131
|
for (const snapshot of existingInventory)
|
|
133
132
|
for (const job of snapshot.jobs) {
|
|
@@ -230,10 +229,11 @@ export async function runDaemon(argv) {
|
|
|
230
229
|
if (targetRef !== 'refs/heads/main')
|
|
231
230
|
throw new Error('GitHub sync only supports local main as merge target');
|
|
232
231
|
const branch = sourceRef.slice('refs/heads/'.length), base = targetRef.slice('refs/heads/'.length);
|
|
233
|
-
|
|
232
|
+
const scopedKey = unifiedRelayStoreEnabled() ? `${snapshot.path}#${discovered.id}` : snapshot.path;
|
|
233
|
+
let relay = relayStores.get(scopedKey);
|
|
234
234
|
if (!relay) {
|
|
235
|
-
relay = new SQLiteRelayStore(snapshot.path);
|
|
236
|
-
relayStores.set(
|
|
235
|
+
relay = new SQLiteRelayStore(snapshot.path, unifiedRelayStoreEnabled() ? discovered.id : undefined);
|
|
236
|
+
relayStores.set(scopedKey, relay);
|
|
237
237
|
}
|
|
238
238
|
const canonical = repositoryPaths.get(discovered.repository) ?? await resolveManagedProjectPath(discovered.repository, explicitProjectPath, workspaceRoot);
|
|
239
239
|
// A completed local Job may never have had a display PR. Publish main
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { eventFor } from './events.js';
|
|
2
|
-
import { SQLiteRelayStore, relayStoreDirectory, relayStorePath } from './relay-store.js';
|
|
2
|
+
import { SQLiteRelayStore, relayStoreDirectory, relayStorePath, relayUnifiedStorePath, unifiedRelayStoreEnabled } from './relay-store.js';
|
|
3
3
|
/** Single-Job service uses the same durable publish facts as the repository daemon. */
|
|
4
4
|
export async function handleServicePublication(job, wake, bus, directory = relayStoreDirectory()) {
|
|
5
5
|
if (!wake.publication && !wake.publishFailure)
|
|
6
6
|
return;
|
|
7
|
-
const sqlite = new SQLiteRelayStore(relayStorePath(directory, job.repository, job.prNumber));
|
|
7
|
+
const sqlite = new SQLiteRelayStore(unifiedRelayStoreEnabled() ? relayUnifiedStorePath(directory) : relayStorePath(directory, job.repository, job.prNumber), unifiedRelayStoreEnabled() ? job.id : undefined);
|
|
8
8
|
try {
|
|
9
9
|
await sqlite.recordPublication(job.id, wake.deliveryId, wake.publication, wake.publishFailure ?? null, wake.publicationObservedAt);
|
|
10
10
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Enforce immutable Job-to-repository identity; PR number may bind once from 0. */
|
|
2
|
+
export function ensureUnifiedJobRegistry(db, job, expectedJobId) {
|
|
3
|
+
if (job.id !== expectedJobId)
|
|
4
|
+
throw new Error(`Wrong scoped Job ID ${job.id}; expected ${expectedJobId}`);
|
|
5
|
+
db.prepare('INSERT INTO source_registry(job_id,source_file,repository,pr_number) VALUES(?,?,?,?) ON CONFLICT(job_id) DO NOTHING')
|
|
6
|
+
.run(job.id, `unified:${job.id}`, job.repository, job.prNumber);
|
|
7
|
+
const binding = db.prepare('SELECT repository,pr_number FROM source_registry WHERE job_id=?')
|
|
8
|
+
.get(job.id);
|
|
9
|
+
if (binding?.repository !== job.repository || !Number.isInteger(binding.pr_number))
|
|
10
|
+
throw new Error(`Job ${job.id} has conflicting repository identity`);
|
|
11
|
+
if (binding.pr_number > 0 && job.prNumber !== binding.pr_number)
|
|
12
|
+
throw new Error(`Job ${job.id} cannot change an established PR binding`);
|
|
13
|
+
if (binding.pr_number === 0 && job.prNumber > 0)
|
|
14
|
+
db.prepare('UPDATE source_registry SET pr_number=? WHERE job_id=? AND pr_number=0').run(job.prNumber, job.id);
|
|
15
|
+
}
|
|
16
|
+
/** Preserve atomic create-if-absent across all Job scopes in the single SQLite. */
|
|
17
|
+
export function createUnifiedJobIfAbsent(db, job, writeSnapshot) {
|
|
18
|
+
db.exec('BEGIN IMMEDIATE');
|
|
19
|
+
try {
|
|
20
|
+
const existing = db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(job.id);
|
|
21
|
+
if (existing?.json) {
|
|
22
|
+
db.exec('COMMIT');
|
|
23
|
+
return JSON.parse(existing.json);
|
|
24
|
+
}
|
|
25
|
+
writeSnapshot(new Date().toISOString());
|
|
26
|
+
db.exec('COMMIT');
|
|
27
|
+
return structuredClone(job);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
db.exec('ROLLBACK');
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Job-scoped PR projection facts; never conflate the historic singleton id=1. */
|
|
2
|
+
export function saveUnifiedPullRequestFacts(db, jobId, facts) {
|
|
3
|
+
db.prepare(`INSERT INTO pull_request_facts(scope_job_id,id,state,head_ref,head_sha,observed_at) VALUES(?,1,?,?,?,?)
|
|
4
|
+
ON CONFLICT(scope_job_id,id) DO UPDATE SET state=excluded.state,head_ref=excluded.head_ref,
|
|
5
|
+
head_sha=excluded.head_sha,observed_at=excluded.observed_at
|
|
6
|
+
WHERE excluded.observed_at >= pull_request_facts.observed_at
|
|
7
|
+
AND (pull_request_facts.state='OPEN'
|
|
8
|
+
OR (pull_request_facts.state='CLOSED' AND excluded.state IN ('CLOSED','MERGED'))
|
|
9
|
+
OR (pull_request_facts.state='MERGED' AND excluded.state='MERGED'))`)
|
|
10
|
+
.run(jobId, facts.state, facts.head?.ref ?? null, facts.head?.sha ?? null, facts.observedAt);
|
|
11
|
+
}
|