agents-relay 2.0.97 → 2.0.99

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 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 job-1`,
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 is optional for compatibility. Existing PR adoption is an explicit separate command: job adopt.
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 legacy PR as a SQLite-authoritative Job.
67
+ 'job adopt': `Adopt an existing PR as a SQLite-authoritative Job.
68
68
 
69
- Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. This is the only creation path that binds an existing PR.`,
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 job-1 --priority P0 --no-auto-merge`,
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 job-1`,
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 job-1 --task-id build --adapter chatgpt --provider chatgpt --model gpt-5.6-sol --reasoning high`,
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 job-1 --prompt "Plan the full implementation now; keep independent work parallel."`,
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 job-1`,
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 job-1 --title "Objective"`,
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 job-1`,
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.
@@ -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 adoptLegacyJob below so the normal contract cannot accidentally
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
- /** Explicit compatibility path for adopting an already-existing PR. */
92
- export async function adoptLegacyJob(_client, repositoryName, _trustedAuthors, options) {
93
- if (!options.id || !Number.isInteger(options.prNumber) || options.prNumber <= 0)
94
- throw new Error('job adopt requires --id and a positive --pr');
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 matches = (await discoverLocalRelayStoreSnapshots(directory)).flatMap(snapshot => snapshot.jobs).filter(job => job.id === options.id);
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(`Ambiguous existing immutable Job ID ${options.id}`);
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
- const local = openLocalManagedStore(repositoryName, options.prNumber, { directory, jobId: options.id, noGitHub: true });
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.loadJob(options.id);
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: options.id, title: options.title ?? options.id, objective: options.objective, projectPath: options.projectPath,
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 (!adoptId || !Number.isInteger(adoptPr) || adoptPr <= 0)
388
- throw new Error('job adopt requires --id and a positive --pr');
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
- export function generatedEntityPrefix(repository, kind) {
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
- export function isGeneratedEntityId(id, repository, kind) {
11
- if (kind === 'task') {
12
- if (/^[1-9][0-9]*$/.test(id))
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; legacy identifiers remain readable. */
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
- generatedEntityPrefix(repository, kind); // validate repository even though its name no longer appears in the ID
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
- let relay = openStores.get(snapshot.path);
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(snapshot.path, relay);
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 relay = new SQLiteRelayStore(prNumber === 0 ? relayJobStorePath(directory, options.jobId) : relayStorePath(directory, repository, prNumber));
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
- constructor(db) {
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("UPDATE projection_outbox SET status='pending', updated_at=? WHERE status='processing' AND updated_at<=?").run(now, cutoff);
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('SELECT status FROM projection_outbox WHERE operation_key=? ORDER BY id DESC LIMIT 1')
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('SELECT COALESCE(MAX(id),0)+1 AS next_id FROM projection_outbox').get().next_id ?? 1);
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.db.prepare(`
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
- next_attempt_at=NULL,
183
- last_error=NULL,
184
- updated_at=excluded.updated_at
185
- `).run(idempotencyKey, operationKey, request.coalesceKey ?? null, request.operation, JSON.stringify(request.payload), request.priority ?? 0, now, now);
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("UPDATE projection_outbox SET status='processing', updated_at=? WHERE id=? AND status='pending'");
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('DELETE FROM projection_outbox WHERE id=?').run(id);
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('DELETE FROM projection_outbox WHERE id=?').run(id);
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.db.prepare(`INSERT INTO projection_receipt(operation_key,status,attempts,detail_json,recorded_at)
260
- VALUES(?,?,?,?,?) ON CONFLICT(operation_key) DO UPDATE SET status=excluded.status, attempts=excluded.attempts,
261
- detail_json=excluded.detail_json, recorded_at=excluded.recorded_at`).run(operationKey, status, attempts, JSON.stringify(detail ?? null), now);
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('SELECT status FROM projection_outbox WHERE id=?').get(id);
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('DELETE FROM projection_outbox WHERE id=? AND status=?').run(id, 'conflict');
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
+ }
@@ -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
- this.migrate();
56
- this.outbox = new ProjectionOutbox(this.db);
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
- this.writeJobSnapshot(job, new Date().toISOString());
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
- return createJobIfAbsent(this.db, job);
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.db.prepare("SELECT value FROM relay_meta WHERE key='agent_registry_observed_at'").get();
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.db.prepare('DELETE FROM agent_snapshot').run();
220
- const insert = this.db.prepare('INSERT INTO agent_snapshot(agent_id,json,updated_at) VALUES(?,?,?)');
221
- for (const agent of agents)
222
- insert.run(agent.id, JSON.stringify(agent), now);
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.db.prepare(`
260
- INSERT INTO agent_snapshot(agent_id,json,updated_at) VALUES(?,?,?)
261
- ON CONFLICT(agent_id) DO UPDATE SET json=excluded.json, updated_at=excluded.updated_at
262
- `).run(agent.id, JSON.stringify(agent), now);
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.db.prepare(`
266
- INSERT INTO relay_meta(key,value) VALUES('agent_registry_observed_at',?)
267
- ON CONFLICT(key) DO UPDATE SET value=excluded.value
268
- `).run(now);
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.db.prepare(`
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.db.prepare(`
320
- INSERT OR IGNORE INTO event_receipt(event_id,stream,sequence,received_at) VALUES(?,?,?,?)
321
- `).run(eventId, stream, sequence, new Date().toISOString());
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.db.prepare('SELECT id FROM projection_outbox WHERE operation_key=? LIMIT 1').get(key) ?? this.db.prepare('SELECT operation_key FROM projection_receipt WHERE operation_key=?').get(key);
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';
@@ -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 { adoptLegacyJob } from './cli-job-create.js';
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.id !== 'string' || typeof options.prNumber !== 'number')
73
- throw new Error('job.adopt requires repository, id, and prNumber');
74
- const result = await adoptLegacyJob(client, repository, trusted, {
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
- relay = new SQLiteRelayStore(relayStorePath(relayStoreDirectory(), repository, prNumber));
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
- let relay = relayStores.get(snapshot.path);
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(snapshot.path, relay);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.97",
3
+ "version": "2.0.99",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {