agents-relay 2.0.66 → 2.0.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +24 -19
  2. package/dist/adapters.js +1 -1
  3. package/dist/cli-blocker.js +3 -5
  4. package/dist/cli-help.js +12 -10
  5. package/dist/cli-job-create.js +69 -0
  6. package/dist/cli-local-merge-config.js +9 -0
  7. package/dist/cli-support.js +25 -8
  8. package/dist/cli.js +66 -80
  9. package/dist/dashboard-html.js +1 -1
  10. package/dist/dashboard.js +1 -1
  11. package/dist/github-auth.js +4 -3
  12. package/dist/github-webhook.js +13 -5
  13. package/dist/local-authority-store.js +279 -0
  14. package/dist/local-git-merge.js +113 -0
  15. package/dist/local-job-path.js +7 -0
  16. package/dist/local-job-registry.js +23 -0
  17. package/dist/local-managed-store.js +21 -0
  18. package/dist/local-merge-delivery.js +52 -0
  19. package/dist/local-merge-lock.js +103 -0
  20. package/dist/no-github.js +11 -0
  21. package/dist/planned-review-head.js +7 -8
  22. package/dist/pool.js +72 -135
  23. package/dist/projected-store.js +20 -3
  24. package/dist/projection-outbox.js +3 -1
  25. package/dist/publication-store.js +37 -0
  26. package/dist/reconciler-review.js +25 -11
  27. package/dist/reconciler-runtime.js +15 -5
  28. package/dist/reconciler.js +33 -60
  29. package/dist/relay-store.js +108 -6
  30. package/dist/relayd.js +119 -124
  31. package/dist/runtime-status.js +8 -7
  32. package/dist/scheduled-jobs.js +43 -36
  33. package/dist/service-external-actions.js +27 -0
  34. package/dist/service-publication.js +29 -0
  35. package/dist/store-github.js +17 -6
  36. package/dist/task-update.js +1 -1
  37. package/dist/workflow/contracts.js +10 -1
  38. package/dist/workflow/definitions-v2.js +2 -0
  39. package/dist/workflow/job-reconcile-v2.js +9 -60
  40. package/dist/workflow/runtime.js +18 -3
  41. package/package.json +5 -3
  42. package/workflow/job-lifecycle.mmd +19 -60
  43. package/workflow/review-lifecycle.mmd +2 -8
package/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # Agents Relay
2
2
 
3
- Agents Relay is a generic TypeScript runtime for durable asynchronous agent jobs coordinated through GitHub pull requests. The PR body stores the durable Job marker plus human-readable Job objective, while PR comments store durable Task and other append-friendly records; correlated terminal events are authoritative for model-backed task lifecycle transitions.
3
+ Agents Relay is a TypeScript runtime for durable asynchronous agent Jobs driven locally by SQLite and canonical workflow Graphs. The EventBus wakes reconciliation; an EventBus message or GitHub PR comment is never the lifecycle authority. Job, Plan, Task, Review, Execution, Lease and Recovery state survive daemon restarts in SQLite.
4
+
5
+ V2 uses a read-only local SQLite Job Registry for discovery. The immutable jobId selects the Job Store even when prNumber is zero; scheduled slot claims are atomic across processes. GitHub is **audit-only**: its PR/head/publish events are persisted for reporting, never consulted to start, stop, or complete local Workflows. Local Git delivery and its confirmed merge receipt—not a remote PR MERGED fact—gate Graph completion. Job creation and local Task operations require no GitHub authentication or PR.
4
6
 
5
- V2 separates **operational execution state** from that collaboration record. A local RelayStore may use SQLite/WAL for runtime snapshots, event idempotency and a durable GitHub projection outbox. ProjectedDurableStore bootstraps from the existing GitHub representation, commits Job/Plan/Task runtime changes locally, and lets GitHubProjectionWriter project them asynchronously. GitHub therefore remains the human-readable durable Job/Plan/Task/Review surface without being required for every high-frequency runtime transition.
6
7
 
7
8
  The canonical V2 architecture contract is
8
9
  [DESIGN-PRINCIPLES.md](DESIGN-PRINCIPLES.md). README sections below describe
@@ -10,23 +11,27 @@ the runtime; they do not replace that contract.
10
11
 
11
12
  ## V2 architecture
12
13
 
13
- ```mermaid
14
+ \`\`\`mermaid
14
15
  flowchart LR
15
- Human[Human / orchestrator] --> GH[GitHub PR + comments<br/>collaboration + audit surface]
16
- GH --> Inbound[Typed inbound GitHub facts]
17
- Worker[Workers / adapters] --> Events[Event Bus<br/>lifecycle + progress facts]
18
- Inbound --> Coord[ReconcileCoordinator]
19
- Events --> Coord
20
- Coord --> Graphs[Executable Mermaid graphs<br/>workflow authority]
21
- Graphs --> Runtime[TypeScript primitives<br/>facts + guards + effects]
22
- Runtime --> Store[(SQLite/WAL RelayStore<br/>operational execution truth)]
23
- Store --> Outbox[Projection outbox]
24
- Outbox --> Writer[GitHubProjectionWriter]
25
- Writer --> GH
26
- Store --> Views[Dashboard / scheduler / recovery]
27
- ```
28
-
29
- RelayStore owns local operational execution facts; GitHub remains the human-readable collaboration and audit projection. Worker events are authoritative lifecycle inputs for model-backed tasks, but they are not a separate durable database. The graph engine owns workflow branching; TypeScript owns facts, persistence, locking/claims, I/O, and primitive guard/action implementations.
16
+ Human[Human / orchestrator] --> CLI[CLI / Dashboard]
17
+ CLI --> Store[(SQLite / WAL<br/>Job Task Plan Review truth)]
18
+ Worker[Workers / adapters] --> Events[EventBus<br/>wake + observations]
19
+ Events --> Coordinator[ReconcileCoordinator]
20
+ Store --> Coordinator
21
+ Coordinator --> Graphs[Canonical Mermaid Graph Engine]
22
+ Graphs --> Effects[Stateless actions / effects]
23
+ Effects --> Store
24
+ Store --> Views[Dashboard / Scheduler / Recovery]
25
+ Effects --> Outbox[Durable external-action outbox]
26
+ Outbox --> GH[GitHub Commit / PR / Review / Merge API]
27
+ GH --> Facts[Confirmed external facts]
28
+ Facts --> Store
29
+ \`\`\`
30
+
31
+ SQLite holds the sole lifecycle authority; the Graph Engine selects every transition.
32
+ GitHub operations are explicitly requested and confirmed by adapters, never used to
33
+ discover or reconstruct Job/Task state. EventBus delivery wakes reconciliation;
34
+ it cannot replace the durable SQLite record.
30
35
 
31
36
  ### Workflow graph navigation
32
37
 
@@ -53,7 +58,7 @@ These paths always represent the current canonical graphs. Historical graph defi
53
58
 
54
59
  ## Quick start
55
60
 
56
- For GitHub-backed work, create or adopt the PR through Agents Relay itself. This guarantees the durable job marker exists before any managed task is submitted.
61
+ For new work, create a local Job through Agents Relay. The CLI can bind a known PR number without GitHub access; creating a new PR head uses the external GitHub adapter. Tasks are validated against the SQLite Job, not GitHub markers.
57
62
 
58
63
  ~~~sh
59
64
  cat > job.md <<'EOF'
package/dist/adapters.js CHANGED
@@ -146,7 +146,7 @@ export function parseDriverJson(output) {
146
146
  throw new Error('browser-worker driver returned no submitted receipt');
147
147
  }
148
148
  function browserWorkspaceCli(override = process.env.AGENTS_RELAY_BROWSER_WORKSPACE_COMMAND) {
149
- return override ? { command: override, prefix: [] } : { command: 'npx', prefix: ['browser-workspace'] };
149
+ return override ? { command: override, prefix: [] } : { command: 'uvx', prefix: ['browser-workspace'] };
150
150
  }
151
151
  function browserWorkspaceCommand(cli, args, stdin, signal) {
152
152
  return new Promise((resolve, reject) => {
@@ -1,5 +1,4 @@
1
- import { GitHubStore } from './store.js';
2
- import { githubAuthContext } from './github-auth.js';
1
+ import { openLocalManagedStore } from './local-managed-store.js';
3
2
  import { registerBlockerDependency } from './blockers.js';
4
3
  import { arg, publishDependentUnblocked, repository, withEventBus } from './cli-support.js';
5
4
  import { usage } from './cli-help.js';
@@ -11,8 +10,7 @@ export async function runBlockerCommand(action, args) {
11
10
  const pr = Number(arg(args, '--pr'));
12
11
  if (!id || !Number.isInteger(pr) || pr <= 0)
13
12
  throw new Error('blocker commands require --repo, --pr, and --id for the blocker Job');
14
- const auth = await githubAuthContext(args);
15
- const blockerStore = new GitHubStore(auth.client, repo, pr, auth.trustedAuthors);
13
+ const blockerStore = openLocalManagedStore(repo, pr).store;
16
14
  if (action === 'show') {
17
15
  console.log(JSON.stringify(await blockerStore.load(id), null, 2));
18
16
  return;
@@ -36,7 +34,7 @@ export async function runBlockerCommand(action, args) {
36
34
  const relation = await registerBlockerDependency({
37
35
  blockerStore,
38
36
  blockerJobId: id,
39
- dependentStore: new GitHubStore(auth.client, dependentRepo, dependentPr, auth.trustedAuthors),
37
+ dependentStore: openLocalManagedStore(dependentRepo, dependentPr).store,
40
38
  dependentJobId: dependentJob,
41
39
  dependentTaskId: dependentTask,
42
40
  resolutionMode: modeValue,
package/dist/cli-help.js CHANGED
@@ -1,12 +1,14 @@
1
1
  export const usage = 'agents-relay <job|task|review|plan|blocker|projection|escalation|context|init|record|agent-register|agent-discover|status|reconcile|serve> [options]';
2
2
  const help = {
3
- root: `Agents Relay coordinates durable asynchronous agent jobs through GitHub pull requests.
3
+ root: `Agents Relay coordinates durable asynchronous agent jobs through local SQLite and workflow graphs.
4
4
 
5
5
  Usage:
6
- npx agents-relay <command> [options]
6
+ npx agents-relay <command> [options] [--no-github]
7
+
8
+ --no-github: SQLite/Graph-only mode. GitHub auth/API, PR creation, merge and fetch are disabled. Local completion is Graph-owned.
7
9
 
8
10
  Commands:
9
- job create|update|show|list|merge|unblock|cancel Manage durable GitHub-backed jobs
11
+ job create|update|show|list|merge|unblock|cancel Manage SQLite-authoritative jobs with optional GitHub PRs
10
12
  task create|update|show|list|complete|retry|unblock|cancel Manage durable tasks
11
13
  review request Request graph-owned review (optional thread override)
12
14
  plan Invoke the Planner for an existing managed Job
@@ -24,7 +26,7 @@ Commands:
24
26
 
25
27
  Run 'npx agents-relay <command> --help' for command options and examples.
26
28
  Use 'node dist/cli.js' only for explicit local development/demo mode.`,
27
- job: `Manage GitHub-backed durable jobs.
29
+ job: `Manage SQLite-authoritative durable jobs.
28
30
 
29
31
  Usage:
30
32
  npx agents-relay job <create|update|show|list|merge|unblock|cancel> [options]
@@ -34,7 +36,7 @@ Actions:
34
36
  update Partially update mutable metadata on a healthy existing Job
35
37
  show Show one durable Job
36
38
  list List managed Jobs in a repository
37
- merge Merge the backing PR and complete the Job
39
+ merge Queue a retryable external PR merge; Graph completes the Job only after confirmation
38
40
  unblock Authoritatively invalidate current BLOCKED decisions and trigger replanning/resume
39
41
  cancel Close the backing PR and cancel the Job
40
42
 
@@ -54,10 +56,10 @@ When provided, --thread-id persists a reviewer destination; otherwise the Job re
54
56
 
55
57
  Required: --repo OWNER/REPO --pr NUMBER --id JOB_ID.
56
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.`,
57
- 'job create': `Create a durable Job and its trusted marker.
59
+ 'job create': `Create a SQLite-authoritative Job.
58
60
 
59
61
  Required: --repo OWNER/REPO, --id JOB_ID, and either --head BRANCH or --pr NUMBER.
60
- With --head, reuse/create the PR for that head. With --pr, bind an existing unmanaged PR.
62
+ With --head, reuse/create the PR for that head. With --pr, bind an external PR reference locally without a GitHub query.
61
63
  Common: --base BRANCH (default: main), --title TEXT, --objective-file PATH|- (preferred), --objective MARKDOWN (short/simple only), --project-path RELATIVE_PATH, --priority P0|P1|P2|P3, --mode fixed|autonomous, --no-review, --tag JSON, --reviewer-thread-id ID, --creator-thread-id ID. --objective and --objective-file are mutually exclusive. For a new --head Job, one is required and must contain enough standalone context to complete the Job without the originating chat/thread. Objective Markdown is also the default PR body; --body is an explicit override.
62
64
  Job creation always enables auto-merge and never creates executable tasks. The orchestrator decides the initial task batch separately. Re-running create against an already healthy Job is idempotent and does not mutate its metadata.
63
65
 
@@ -66,8 +68,8 @@ Example:
66
68
  'job update': `Partially update mutable metadata on a healthy existing Job.
67
69
 
68
70
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
69
- Mutable: --title, --objective-file PATH|- (preferred) or --objective MARKDOWN, --project-path RELATIVE_PATH, --priority, --mode fixed|autonomous, --schedule, --tag JSON, --auto-merge|--no-auto-merge, --review|--no-review, --reviewer-thread-id ID, --creator-thread-id ID, and continuation options. Updating the objective also updates the PR body.
70
- Unspecified fields are preserved. Marker/binding corruption is never repaired implicitly; update fails with a precise diagnostic instead.
71
+ Mutable: --title, --objective-file PATH|- (preferred) or --objective MARKDOWN, --project-path RELATIVE_PATH, --priority, --mode fixed|autonomous, --schedule, --tag JSON, --auto-merge|--no-auto-merge, --review|--no-review, --reviewer-thread-id ID, --creator-thread-id ID, and continuation options. The objective updates local SQLite; PR body changes are independent external actions.
72
+ Unspecified fields are preserved. Local Job/PR identity mismatch is rejected.
71
73
 
72
74
  Example:
73
75
  npx agents-relay job update --repo OWNER/REPO --pr 12 --id job-1 --priority P0 --no-auto-merge`,
@@ -83,7 +85,7 @@ Required: --repo OWNER/REPO.
83
85
 
84
86
  Example:
85
87
  npx agents-relay job list --repo OWNER/REPO`,
86
- 'job merge': `Merge the backing pull request and complete the durable Job.
88
+ 'job merge': `Request an idempotent external pull-request merge; return QUEUED until confirmed, when the Graph Engine completes the Job.
87
89
 
88
90
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. The current PR head SHA is pinned into the merge request.`,
89
91
  'job unblock': `Authoritatively unblock the effective BLOCKED work Tasks in one Job.\n\nRequired: --repo OWNER/REPO --pr NUMBER --id JOB_ID --reason TEXT. Autonomous Jobs transition blocked work to historical UNBLOCKED state and wake Planner with unblock facts as Plan trigger/context; fixed Jobs record UNBLOCKED first and then resume according to fixed-job retry semantics.`,
@@ -0,0 +1,69 @@
1
+ import { openLocalManagedStore } from './local-managed-store.js';
2
+ import { createJobEntity } from './workflow/entity-creation.js';
3
+ import { normalizeThreadId } from './reviewer-thread.js';
4
+ import { discoverLocalRelayStoreSnapshots } from './relay-store.js';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ /**
8
+ * Job lifecycle bootstrap: SQLite is authoritative. PR creation is an
9
+ * optional one-time external adapter; an explicit PR number is offline-safe.
10
+ */
11
+ export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAuthors, options) {
12
+ if (!options.id)
13
+ throw new Error('--id is required for managed local Jobs');
14
+ const number = options.prNumber ?? 0;
15
+ if (!Number.isInteger(number) || number < 0)
16
+ throw new Error('Invalid external PR binding number');
17
+ const pr = { number, title: options.title ?? options.id,
18
+ body: options.body ?? options.objective ?? '', state: 'OPEN', head: '', base: options.base ?? '' };
19
+ const directory = options.storeDirectory ?? join(homedir(), '.agents-relay', 'stores');
20
+ const alreadyKnown = (await discoverLocalRelayStoreSnapshots(directory)).flatMap(snapshot => snapshot.jobs.filter(job => job.id === options.id).map(job => ({ job, path: snapshot.path })));
21
+ if (alreadyKnown.length > 1)
22
+ throw new Error('Duplicate immutable Job ID across SQLite stores: ' + options.id);
23
+ if (alreadyKnown.length === 1) {
24
+ const existing = alreadyKnown[0].job;
25
+ if (existing.repository !== repositoryName || (options.prNumber !== undefined && existing.prNumber !== number))
26
+ throw new Error('Immutable Job ID is already bound to a different repository or external PR');
27
+ return { job: existing, pr: { ...pr, number: existing.prNumber } };
28
+ }
29
+ const local = openLocalManagedStore(repositoryName, number, {
30
+ directory, jobId: options.id, noGitHub: true,
31
+ });
32
+ try {
33
+ const existing = await local.relay.loadJob(options.id);
34
+ if (existing)
35
+ return { job: existing, pr };
36
+ const now = new Date().toISOString();
37
+ const job = createJobEntity({
38
+ id: options.id,
39
+ title: options.title || pr.title || 'Agents Relay job',
40
+ objective: options.objective || pr.body || undefined,
41
+ projectPath: options.projectPath,
42
+ description: pr.body || undefined,
43
+ priority: options.priority ?? 'P2',
44
+ executionMode: options.executionMode ?? 'fixed',
45
+ tag: options.tag,
46
+ autoMerge: true,
47
+ ...(options.localMergeSource && options.localMergeTarget ? { localMerge: { sourceRef: options.localMergeSource, targetRef: options.localMergeTarget, status: 'pending' } } : {}),
48
+ reviewEnabled: options.reviewEnabled ?? true,
49
+ ...(options.reviewerThreadId
50
+ ? { reviewerThreadId: normalizeThreadId(options.reviewerThreadId, 'reviewer thread ID') }
51
+ : {}),
52
+ ...(options.creatorThreadId
53
+ ? { creatorThreadId: normalizeThreadId(options.creatorThreadId, 'creator thread ID') }
54
+ : {}),
55
+ prNumber: pr.number,
56
+ repository: repositoryName,
57
+ continuation: options.continuation ?? null,
58
+ createdAt: now,
59
+ updatedAt: now,
60
+ tasks: [],
61
+ ...(options.schedule ? { schedule: options.schedule } : {}),
62
+ }, { activate: Boolean(options.schedule) });
63
+ await local.store.saveJob(job);
64
+ return { job: await local.store.load(options.id), pr };
65
+ }
66
+ finally {
67
+ local.relay.close();
68
+ }
69
+ }
@@ -0,0 +1,9 @@
1
+ export function configureLocalGitMerge(job, source, target, localOnly) {
2
+ // The GitHub integration is observational; local merge applies in either mode.
3
+ void localOnly;
4
+ if (!source || !target)
5
+ throw new Error('Supply both --local-merge-source and --local-merge-target');
6
+ if (!['ACTIVE', 'DRAFT'].includes(job.state))
7
+ throw new Error('Cannot reconfigure merge of a terminal Job');
8
+ job.localMerge = { sourceRef: source, targetRef: target, status: 'pending' };
9
+ }
@@ -1,6 +1,11 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises';
2
2
  import { InMemoryStore, GitHubStore } from './store.js';
3
3
  import { githubAuthContext } from './github-auth.js';
4
+ import { noGitHubMode } from './no-github.js';
5
+ import { basename } from 'node:path';
6
+ import { createJobIdRegistryResolver } from './local-job-registry.js';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
4
9
  import { NatsEventBus, eventFor } from './events.js';
5
10
  import { parseJobSchedule } from './scheduler.js';
6
11
  import { parseJobTagJson } from './task-tag.js';
@@ -51,20 +56,32 @@ export async function textOption(args, inlineFlag, fileFlag) {
51
56
  export function integerArg(args, name, fallback) { if (!hasArg(args, name))
52
57
  return fallback; const value = Number(arg(args, name)); if (!Number.isInteger(value) || value < 0)
53
58
  throw new Error(`${name} must be a non-negative integer`); return value; }
54
- export function repository(args) { const value = arg(args, '--repo'); if (!value)
59
+ export function repository(args) { const value = arg(args, '--repo'); if (!value && noGitHubMode(args))
60
+ return 'local/' + basename(process.cwd()); if (!value)
55
61
  throw new Error('--repo is required for GitHub-backed operation'); return value; }
56
62
  export function pullRequest(args) { const value = Number(arg(args, '--pr')); if (!Number.isInteger(value) || value <= 0)
57
63
  throw new Error('--pr is required for GitHub-backed operation'); return value; }
58
64
  async function localStore(file) { const parsed = JSON.parse(await readFile(file, 'utf8')); const store = new InMemoryStore(parsed); return { store, job: await store.load(parsed.id) }; }
59
65
  export async function githubStore(args) { const auth = await githubAuthContext(args); return new GitHubStore(auth.client, repository(args), pullRequest(args), auth.trustedAuthors); }
60
- export async function storeFor(args) { const repo = arg(args, '--repo'); const pr = arg(args, '--pr'); if (repo && pr) {
61
- const store = await githubStore(args);
66
+ export async function storeFor(args) {
67
+ const file = arg(args, '--file');
68
+ if (file) {
69
+ const loaded = await localStore(file);
70
+ return { ...loaded, localFile: file };
71
+ }
72
+ const repo = noGitHubMode(args) ? repository(args) : arg(args, '--repo');
62
73
  const jobId = arg(args, '--id');
63
- if (!jobId)
64
- throw new Error('--id is required for GitHub-backed operation');
65
- return { store, job: await store.load(jobId), localFile: null };
66
- } const file = arg(args, '--file'); if (!file)
67
- throw new Error('Local mode is demo/test only; pass --file explicitly'); const loaded = await localStore(file); return { store: loaded.store, job: loaded.job, localFile: file }; }
74
+ if (!repo || !jobId)
75
+ throw new Error('Local Job requires --repo and --id (or explicit --file)');
76
+ const directory = arg(args, '--store-dir') || join(homedir(), '.agents-relay', 'stores');
77
+ const binding = await createJobIdRegistryResolver(directory, new Map())(jobId);
78
+ if (!binding || binding.job.repository !== repo)
79
+ throw new Error('Immutable local Job not found for repository: ' + jobId);
80
+ const suppliedPr = arg(args, '--pr');
81
+ if (suppliedPr && binding.job.prNumber !== Number(suppliedPr))
82
+ throw new Error('Local Job external PR binding mismatch');
83
+ return { store: binding.store, job: await binding.store.load(jobId), localFile: null };
84
+ }
68
85
  export async function saveLocal(file, job) { if (file)
69
86
  await writeFile(file, JSON.stringify(job, null, 2)); }
70
87
  export async function relayControlJob(args, jobId) {
package/dist/cli.js CHANGED
@@ -1,26 +1,31 @@
1
1
  #!/usr/bin/env node
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { openLocalManagedStore } from './local-managed-store.js';
5
+ import { configureLocalGitMerge } from './cli-local-merge-config.js';
6
+ import { DisabledGitHubClient, noGitHubMode } from './no-github.js';
7
+ import { discoverLocalRelayStoreSnapshots } from './relay-store.js';
2
8
  import { realpathSync } from 'node:fs';
3
9
  import { once } from 'node:events';
4
10
  import { fileURLToPath } from 'node:url';
5
11
  import { randomUUID } from 'node:crypto';
6
- import { dashboardManagedJobs, InMemoryStore, GitHubStore, resolvePullRequest, listManagedGitHubJobs } from './store.js';
7
- import { githubAuthContext } from './github-auth.js';
12
+ import { dashboardManagedJobs, InMemoryStore } from './store.js';
8
13
  import { taskOverallTimeoutMs } from './types.js';
9
14
  import { Reconciler } from './reconciler.js';
10
15
  import { dashboardJobSetState, dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
11
16
  export { dashboardJobSetState, dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
12
17
  import { CodexAdapter, ChatGptAdapter } from './adapters.js';
13
18
  import { CodexThreadContinuation, CommandContinuation, WebhookContinuation } from './continuation.js';
14
- import { eventFor } from './events.js';
15
19
  import { serveDashboard } from './dashboard.js';
16
20
  import { codexAndZaiUsageRegistry } from './usage.js';
17
21
  import { decisionMode, executionDecisionEngine } from './decision.js';
18
22
  import { discoverAgents } from './registry.js';
19
23
  import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
20
- import { transitionJobEvent } from './scheduler.js';
21
24
  import { applyOrchestratorStartAction, applyOrchestratorCompleteAction, applyTaskHoldAction, applyTaskQueueAction, dispatchTaskAction } from './task-actions.js';
22
25
  import { applyJobActivateAction, applyJobCancelAction } from './job-actions.js';
23
26
  import { resolveExecutionContext } from './execution-context.js';
27
+ import { startServiceExternalActionFlusher } from './service-external-actions.js';
28
+ import { handleServicePublication } from './service-publication.js';
24
29
  import { defaultWorkspaceRoot, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
25
30
  import { runBlockerCommand } from './cli-blocker.js';
26
31
  import { runEscalationCommand } from './cli-escalation.js';
@@ -35,46 +40,10 @@ export { helpText } from './cli-help.js';
35
40
  import { runReviewCommand } from './cli-review.js';
36
41
  import { normalizeThreadId, runtimeReviewer } from './reviewer-thread.js';
37
42
  export { runtimeReviewer } from './reviewer-thread.js';
38
- import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, githubStore, eventBus, publishWake, publishDependentUnblocked, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, jobTagFromArgs, requestManagedReconciliation, relayControlJob, relayTaskCommand, withEventBus } from './cli-support.js';
43
+ import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, pullRequest, storeFor, saveLocal, eventBus, publishWake, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, jobTagFromArgs, requestManagedReconciliation, relayControlJob, relayTaskCommand, withEventBus } from './cli-support.js';
39
44
  export { SERVICE_DEFAULTS, eventBus, executionMode, chatGptTabClosePolicy } from './cli-support.js';
40
- export async function ensureManagedGitHubJob(client, repositoryName, trustedAuthors, options) {
41
- if (!options.id)
42
- throw new Error('--id is required for managed GitHub jobs');
43
- const pr = await resolvePullRequest(client, repositoryName, { prNumber: options.prNumber, head: options.head, base: options.base, title: options.title, body: options.body });
44
- const store = new GitHubStore(client, repositoryName, pr.number, trustedAuthors);
45
- try {
46
- const existing = await store.load(options.id);
47
- return { job: existing, pr };
48
- }
49
- catch (error) {
50
- if (!(error instanceof Error) || !error.message.startsWith('JOB_MARKER_MISSING:'))
51
- throw error;
52
- }
53
- const now = new Date().toISOString();
54
- const job = createJobEntity({
55
- id: options.id,
56
- title: options.title || pr.title || 'Agents Relay job',
57
- objective: options.objective || pr.body || undefined,
58
- projectPath: options.projectPath,
59
- description: pr.body || undefined,
60
- priority: options.priority ?? 'P2',
61
- executionMode: options.executionMode ?? 'fixed',
62
- tag: options.tag,
63
- autoMerge: true,
64
- reviewEnabled: options.reviewEnabled ?? true,
65
- ...(options.reviewerThreadId ? { reviewerThreadId: normalizeThreadId(options.reviewerThreadId, 'reviewer thread ID') } : {}),
66
- ...(options.creatorThreadId ? { creatorThreadId: normalizeThreadId(options.creatorThreadId, 'creator thread ID') } : {}),
67
- prNumber: pr.number,
68
- repository: repositoryName,
69
- continuation: options.continuation ?? null,
70
- createdAt: now,
71
- updatedAt: now,
72
- tasks: [],
73
- ...(options.schedule ? { schedule: options.schedule } : {}),
74
- }, { activate: Boolean(options.schedule) });
75
- await store.saveJob(job);
76
- return { job: await store.load(options.id), pr };
77
- }
45
+ import { ensureManagedGitHubJob } from './cli-job-create.js';
46
+ export { ensureManagedGitHubJob } from './cli-job-create.js';
78
47
  async function workerTaskFromArgs(job, args, now) {
79
48
  const model = arg(args, '--model');
80
49
  const adapterValue = arg(args, '--adapter');
@@ -166,7 +135,7 @@ async function runManagedTaskCommand(action, args) {
166
135
  async function runTaskCommand(action, args) {
167
136
  if (!['create', 'update', 'show', 'list', 'start', 'complete', 'retry', 'unblock', 'cancel'].includes(action))
168
137
  throw new Error(usage);
169
- const managed = Boolean(arg(args, '--repo') && arg(args, '--pr') && !arg(args, '--file'));
138
+ const managed = !noGitHubMode(args) && Boolean(arg(args, '--repo') && arg(args, '--pr') && !arg(args, '--file'));
170
139
  if (managed && ['create', 'start', 'complete', 'retry', 'cancel'].includes(action)) {
171
140
  await runManagedTaskCommand(action, args);
172
141
  return;
@@ -365,27 +334,35 @@ async function runJobCommand(action, args) {
365
334
  if (invalid)
366
335
  throw new Error(`job create does not accept task execution option ${invalid}; create the job first, then let the orchestrator create the initial plan/task batch`);
367
336
  createObjective = await textOption(args, '--objective', '--objective-file');
368
- if (!arg(args, '--pr') && !createObjective.trim())
337
+ if ((!arg(args, '--pr') || noGitHubMode(args)) && !createObjective.trim())
369
338
  throw new Error('job create with --head requires --objective-file PATH|- or --objective MARKDOWN containing the complete standalone Job context');
370
339
  }
371
- const auth = await githubAuthContext(args);
372
340
  if (action === 'list') {
373
- console.log(JSON.stringify(await listManagedGitHubJobs(auth.client, repo, auth.trustedAuthors), null, 2));
341
+ const snapshots = await discoverLocalRelayStoreSnapshots(join(homedir(), '.agents-relay', 'stores'));
342
+ const overviews = snapshots.flatMap(snapshot => snapshot.jobs.filter(job => job.repository === repo)
343
+ .map(job => ({
344
+ job, githubState: snapshot.facts?.state ?? 'UNKNOWN',
345
+ draft: false, mergedAt: null, closedAt: null, publication: snapshot.publications[job.id]?.latest ?? null, publicationFailure: snapshot.publications[job.id]?.failure ?? null,
346
+ })));
347
+ console.log(JSON.stringify(dashboardManagedJobs(overviews), null, 2));
374
348
  return;
375
349
  }
376
350
  const id = arg(args, '--id');
377
351
  if (!id)
378
- throw new Error('--id is required for managed GitHub jobs');
352
+ throw new Error('--id is required for managed local Jobs');
379
353
  const prValue = arg(args, '--pr');
380
- const prNumber = prValue ? Number(prValue) : undefined;
354
+ const prNumber = prValue ? Number(prValue) : 0;
381
355
  if (prValue && (!Number.isInteger(prNumber) || (prNumber ?? 0) <= 0))
382
356
  throw new Error('--pr must be a positive integer');
383
357
  if (action === 'create') {
384
- if (!prNumber && !arg(args, '--head'))
385
- throw new Error('job create requires either --head or --pr');
386
358
  const objective = createObjective ?? '';
359
+ if (hasArg(args, '--local-merge-source') !== hasArg(args, '--local-merge-target'))
360
+ throw new Error('Supply both --local-merge-source and --local-merge-target');
361
+ const auth = { client: new DisabledGitHubClient(), trustedAuthors: new Set() };
387
362
  const result = await ensureManagedGitHubJob(auth.client, repo, auth.trustedAuthors, {
388
- id,
363
+ id, noGitHub: noGitHubMode(args),
364
+ localMergeSource: arg(args, '--local-merge-source') || undefined,
365
+ localMergeTarget: arg(args, '--local-merge-target') || undefined,
389
366
  title: arg(args, '--title') || undefined,
390
367
  objective: objective || undefined,
391
368
  projectPath: arg(args, '--project-path') || undefined,
@@ -405,10 +382,8 @@ async function runJobCommand(action, args) {
405
382
  console.log(JSON.stringify({ repository: repo, pr: result.pr.number, job: result.job.id, executionMode: result.job.executionMode, state: result.job.state }, null, 2));
406
383
  return;
407
384
  }
408
- if (!prNumber)
409
- throw new Error(`job ${action} requires --pr`);
410
- const store = new GitHubStore(auth.client, repo, prNumber, auth.trustedAuthors);
411
- const job = await store.load(id);
385
+ const loaded = await storeFor(args);
386
+ const { store, job } = loaded;
412
387
  if (action === 'unblock') {
413
388
  await runJobUnblockCommand(args, store, job);
414
389
  return;
@@ -417,22 +392,14 @@ async function runJobCommand(action, args) {
417
392
  console.log(JSON.stringify(job, null, 2));
418
393
  return;
419
394
  }
420
- await store.validateJobBinding(id);
395
+ if (job.repository !== repo || (prValue && job.prNumber !== prNumber)) {
396
+ throw new Error('Local Job/PR binding mismatch');
397
+ }
421
398
  if (action === 'merge') {
422
- if (job.state === 'CANCELLED')
423
- throw new Error(`Cannot merge cancelled job ${job.id}`);
424
- const headSha = await store.headSha();
425
- await store.mergePullRequest(headSha ?? undefined);
426
- await transitionJobEvent(job, 'pr.merged');
427
- await store.saveJob(job);
428
- const resolved = await store.resolveBlockerDependents(id, 'merge');
429
- await withEventBus(args, bus => publishDependentUnblocked(bus, resolved.unblocked, `Blocker Job ${id} resolved (merge)`));
430
- console.log(JSON.stringify(await store.load(id), null, 2));
431
- return;
399
+ throw new Error('GitHub is audit-only. Configure local merge source/target and reconcile the local Job instead.');
432
400
  }
433
401
  if (action === 'cancel') {
434
402
  await applyJobCancelAction(store, job);
435
- await store.closePullRequest();
436
403
  console.log(JSON.stringify(await store.load(id), null, 2));
437
404
  return;
438
405
  }
@@ -446,6 +413,8 @@ async function runJobCommand(action, args) {
446
413
  }
447
414
  if (hasArg(args, '--project-path'))
448
415
  job.projectPath = arg(args, '--project-path') || undefined;
416
+ if (hasArg(args, '--local-merge-source') || hasArg(args, '--local-merge-target'))
417
+ configureLocalGitMerge(job, arg(args, '--local-merge-source'), arg(args, '--local-merge-target'), noGitHubMode(args));
449
418
  if (hasArg(args, '--priority'))
450
419
  job.priority = priority(args, job.priority ?? 'P2');
451
420
  if (hasArg(args, '--mode') || hasArg(args, '--execution-mode'))
@@ -481,7 +450,7 @@ async function runJobCommand(action, args) {
481
450
  console.log(JSON.stringify(await store.load(id), null, 2));
482
451
  }
483
452
  export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
484
- export function runtime(store, args, bus, usageRegistry = codexAndZaiUsageRegistry()) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const policy = chatGptTabClosePolicy(args); const planner = runtimePlanner(args); return new Reconciler(store, { owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, usageSnapshotProvider: () => usageRegistry.snapshot(), decisionEngine: executionDecisionEngine(), decisionMode: decisionMode(), reviewer: runtimeReviewer(args), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: async (job) => resolveRepositoryProjectPath(await resolveManagedProjectPath(job.repository, arg(args, '--project-path'), arg(args, '--workspace-root') || defaultWorkspaceRoot()), job.projectPath) }); }
453
+ export function runtime(store, args, bus, usageRegistry = codexAndZaiUsageRegistry()) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const policy = chatGptTabClosePolicy(args); const planner = runtimePlanner(args); return new Reconciler(store, { noGitHub: noGitHubMode(args), owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, usageSnapshotProvider: () => usageRegistry.snapshot(), decisionEngine: executionDecisionEngine(), decisionMode: decisionMode(), reviewer: runtimeReviewer(args), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: async (job) => resolveRepositoryProjectPath(await resolveManagedProjectPath(noGitHubMode(args) ? undefined : job.repository, arg(args, '--project-path'), arg(args, '--workspace-root') || defaultWorkspaceRoot()), job.projectPath) }); }
485
454
  export function dashboardJobAction(store, reconciler, action) {
486
455
  return async (jobId) => {
487
456
  const job = await store.load(jobId);
@@ -491,7 +460,6 @@ export function dashboardJobAction(store, reconciler, action) {
491
460
  }
492
461
  else if (action === 'cancel') {
493
462
  await applyJobCancelAction(store, job);
494
- await store.closePullRequest?.();
495
463
  return await store.load(job.id);
496
464
  }
497
465
  return await reconciler.wake(job.id);
@@ -505,17 +473,33 @@ export async function createService(args) {
505
473
  const usageWindowDays = Number.isInteger(requestedUsageDays) && requestedUsageDays > 0 ? requestedUsageDays : 1;
506
474
  const usageRegistry = codexAndZaiUsageRegistry(undefined, usageWindowDays);
507
475
  const reconciler = runtime(loaded.store, args, bus, usageRegistry);
508
- const repo = arg(args, '--repo');
476
+ const repo = noGitHubMode(args) ? repository(args) : arg(args, '--repo');
509
477
  let overviewProvider;
510
478
  if (repo) {
511
- const overviewAuth = await githubAuthContext(args);
512
- overviewProvider = async () => dashboardManagedJobs(await listManagedGitHubJobs(overviewAuth.client, repo, overviewAuth.trustedAuthors));
479
+ overviewProvider = async () => {
480
+ const snapshots = await discoverLocalRelayStoreSnapshots(join(homedir(), '.agents-relay', 'stores'));
481
+ return dashboardManagedJobs(snapshots.flatMap(snapshot => snapshot.jobs
482
+ .filter(job => job.repository === repo)
483
+ .map(job => ({
484
+ job, githubState: snapshot.facts?.state ?? 'UNKNOWN', draft: false,
485
+ mergedAt: null, closedAt: null, publication: snapshot.publications[job.id]?.latest ?? null, publicationFailure: snapshot.publications[job.id]?.failure ?? null,
486
+ }))));
487
+ };
513
488
  }
514
489
  const webhookSecret = arg(args, '--github-webhook-secret') || process.env.AGENTS_RELAY_GITHUB_WEBHOOK_SECRET || '';
515
- const webhook = repo && webhookSecret ? { secret: webhookSecret, repositories: new Set([repo]), pullRequest: loaded.job.prNumber, wake: (event) => { const wakeEvent = eventFor(loaded.job.id, 'github', null, 'github.webhook', 'queued', 'GitHub webhook ' + event.event + (event.action ? ':' + event.action : ''), 'orchestrator', { deliveryId: event.deliveryId, event: event.event, action: event.action, repository: event.repository, prNumber: event.pullRequest }); if (bus)
516
- void bus.publish(wakeEvent);
517
- else
518
- void reconciler.reconcile(loaded.job.id); } } : undefined;
490
+ const webhook = !noGitHubMode(args) && repo && webhookSecret ? {
491
+ secret: webhookSecret,
492
+ repositories: new Set([repo]),
493
+ pullRequest: loaded.job.prNumber,
494
+ wake: async (event) => {
495
+ if (event.pullRequestFacts && loaded.store.applyPullRequestFacts) {
496
+ await loaded.store.applyPullRequestFacts({
497
+ ...event.pullRequestFacts, observedAt: event.pullRequestFacts.observedAt ?? new Date().toISOString(),
498
+ });
499
+ }
500
+ await handleServicePublication(await loaded.store.load(loaded.job.id), event, bus);
501
+ },
502
+ } : undefined;
519
503
  const dashboardServer = serveDashboard(loaded.store, Number(arg(args, '--port', String(SERVICE_DEFAULTS.port))), bus, Number(arg(args, '--refresh-ms', String(SERVICE_DEFAULTS.dashboardRefreshMs))), loaded.job.id, usageRegistry, overviewProvider, webhook, undefined, undefined, undefined, {
520
504
  start: dashboardJobAction(loaded.store, reconciler, 'start'),
521
505
  reconcile: dashboardJobAction(loaded.store, reconciler, 'reconcile'),
@@ -529,6 +513,7 @@ export async function createService(args) {
529
513
  const stopWatcher = await reconciler.watch(loaded.job.id);
530
514
  const watchdog = setInterval(() => void reconciler.reconcile(loaded.job.id), Number(arg(args, '--interval', String(SERVICE_DEFAULTS.watchdogMs))));
531
515
  watchdog.unref();
516
+ const stopExternalActions = startServiceExternalActionFlusher(loaded.store, reconciler, loaded.job.id, Number(arg(args, '--projection-ms', String(SERVICE_DEFAULTS.projectionFlushMs))));
532
517
  let stopping = null;
533
518
  const service = {
534
519
  store: loaded.store,
@@ -540,6 +525,7 @@ export async function createService(args) {
540
525
  if (stopping)
541
526
  return stopping;
542
527
  clearInterval(watchdog);
528
+ stopExternalActions();
543
529
  service.watchdogCleared = true;
544
530
  const serverClosed = new Promise((resolve, reject) => { dashboardServer.close(error => error ? reject(error) : resolve()); });
545
531
  dashboardServer.closeAllConnections();
@@ -576,7 +562,7 @@ export function isEntrypoint(moduleUrl) {
576
562
  }
577
563
  }
578
564
  async function main() {
579
- const [command, ...args] = process.argv.slice(2);
565
+ const [command, ...args] = process.argv.slice(2).filter(value => value !== '--no-github');
580
566
  if (!command || command === '--help' || command === '-h') {
581
567
  console.log(helpText());
582
568
  return;
@@ -626,7 +612,7 @@ async function main() {
626
612
  let store;
627
613
  let file = null;
628
614
  if (arg(args, '--repo') && arg(args, '--pr'))
629
- store = await githubStore(args);
615
+ store = openLocalManagedStore(repository(args), pullRequest(args)).store;
630
616
  else {
631
617
  file = arg(args, '--file');
632
618
  if (!file)
@@ -709,7 +695,7 @@ async function main() {
709
695
  }
710
696
  if (command === 'reconcile') {
711
697
  await withEventBus(args, async (bus) => {
712
- if (!localFile) {
698
+ if (!localFile && !noGitHubMode(args)) {
713
699
  await requestManagedReconciliation(bus, job);
714
700
  console.log(JSON.stringify(await store.load(job.id), null, 2));
715
701
  return;
@@ -388,7 +388,7 @@ function jobChatRef(job){return repoName(job?.repository)+'/pr#'+value(job?.prNu
388
388
  function chatShortcutUrl(prompt){return 'https://chatgpt.com/?prompt='+encodeURIComponent(prompt);}
389
389
  const CHAT_SHORTCUT_TARGET='agents-relay-shortcut';
390
390
  function chatShortcutField(job){const box=element('div','detail-group');box.append(element('p','detail-label',text('ChatGPT shortcut')));const select=document.createElement('select');select.className='job-filter job-chat-shortcut-select';select.setAttribute('aria-label','ChatGPT shortcut');select.append(new Option('',''));for(const item of parseChatShortcuts(chatShortcutText()))select.append(new Option(item.name,item.prompt));select.value='';select.addEventListener('change',()=>{if(!select.value)return;const prompt=select.value.replaceAll('%1',jobChatRef(job));window.open(chatShortcutUrl(prompt),CHAT_SHORTCUT_TARGET,'noopener');select.value='';});box.append(select);return box;}
391
- function renderJobList(){jobList.replaceChildren();const visible=filteredJobs();if(!jobs.length){jobList.append(element('li','empty',text('No durable jobs discovered.')));return;}if(!visible.length){jobList.append(element('li','empty',text('No jobs match the selected repo.')));return;}for(const job of visible){const row=element('div','job-row'+(isCompleteUnmerged(job)?' complete-unmerged':''));row.setAttribute('aria-current',String(jobSelectionKey(job)===selectedJob));row.addEventListener('click',()=>selectJob(job));const content=element('div','job-content');const select=document.createElement('button');select.type='button';select.className='job-button';select.setAttribute('aria-current',String(jobSelectionKey(job)===selectedJob));select.title=job.title||job.id;select.addEventListener('click',event=>{event.stopPropagation();selectJob(job);});select.append(element('span','job-title',text(repoName(job.repository)+'/PR#'+value(job.prNumber)+': '+value(job.title||job.id))));const states=element('span','job-status-line');states.append(element('span','badge '+value(job.state),text(value(job.state))),element('span','badge '+value(job.runtimeStatus||'IDLE'),text(value(job.runtimeStatus||'IDLE'))));if(job.executionMode==='autonomous')states.append(element('span','badge flag-autonomous',text('AUTONOMOUS')));if(job.reviewEnabled!==false&&job.githubState!=='MERGED')states.append(element('span','badge flag-review',text('REVIEW REQUIRED')));if(job.githubState==='MERGED'&&job.publication?.version)states.append(element('span','badge SUCCEEDED',text('v'+job.publication.version)));if(isCompleteUnmerged(job))states.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));const primary=element('div','job-primary');primary.append(states);const lastLine=element('div','job-last-line');lastLine.append(element('span','subtle',text(compactCounts(counts(job.tasks)))),element('span','subtle',text(relativeTime(job.updatedAt))));content.append(select,primary,lastLine);row.append(content);jobList.append(element('li','',row));}}
391
+ function renderJobList(){jobList.replaceChildren();const visible=filteredJobs();if(!jobs.length){jobList.append(element('li','empty',text('No durable jobs discovered.')));return;}if(!visible.length){jobList.append(element('li','empty',text('No jobs match the selected repo.')));return;}for(const job of visible){const row=element('div','job-row'+(isCompleteUnmerged(job)?' complete-unmerged':''));row.setAttribute('aria-current',String(jobSelectionKey(job)===selectedJob));row.addEventListener('click',()=>selectJob(job));const content=element('div','job-content');const select=document.createElement('button');select.type='button';select.className='job-button';select.setAttribute('aria-current',String(jobSelectionKey(job)===selectedJob));select.title=job.title||job.id;select.addEventListener('click',event=>{event.stopPropagation();selectJob(job);});select.append(element('span','job-title',text(repoName(job.repository)+'/PR#'+value(job.prNumber)+': '+value(job.title||job.id))));const states=element('span','job-status-line');states.append(element('span','badge '+value(job.state),text(value(job.state))),element('span','badge '+value(job.runtimeStatus||'IDLE'),text(value(job.runtimeStatus||'IDLE'))));if(job.executionMode==='autonomous')states.append(element('span','badge flag-autonomous',text('AUTONOMOUS')));if(job.reviewEnabled!==false&&job.githubState!=='MERGED')states.append(element('span','badge flag-review',text('REVIEW REQUIRED')));if(job.publication?.version)states.append(element('span','badge SUCCEEDED',text('v'+job.publication.version)));if(job.publicationFailure)states.append(element('span','badge FAILED',text('PUBLISH FAILED: '+value(job.publicationFailure.stage))));if(isCompleteUnmerged(job))states.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));const primary=element('div','job-primary');primary.append(states);const lastLine=element('div','job-last-line');lastLine.append(element('span','subtle',text(compactCounts(counts(job.tasks)))),element('span','subtle',text(relativeTime(job.updatedAt))));content.append(select,primary,lastLine);row.append(content);jobList.append(element('li','',row));}}
392
392
  function taskTagDisplay(job,task){const display=task.tag&&job?.tag?.values?.[task.tag];return typeof display==='string'&&display?display:null;}
393
393
  function taskSubtreeComplete(task,children){return ['SUCCEEDED','CANCELLED'].includes(task.state)&&(children.get(task.id)||[]).every(child=>taskSubtreeComplete(child,children));}
394
394
  function taskNode(task,children,depth){const li=element('li');const nested=children.get(task.id)||[];const line=element('div','task-line');if(nested.length){const key=(selectedJob||'')+':'+task.id;const collapsed=collapsedTasks.has(key)||(!expandedTasks.has(key)&&taskSubtreeComplete(task,children));const fold=document.createElement('button');fold.type='button';fold.className='task-fold';fold.setAttribute('aria-expanded',String(!collapsed));fold.setAttribute('aria-label',(collapsed?'Expand ':'Collapse ')+task.id);fold.textContent=collapsed?'▸':'▾';line.append(fold);const ul=element('ul','tree');ul.hidden=collapsed;for(const child of nested)ul.append(taskNode(child,children,depth+1));fold.addEventListener('click',()=>{const next=!ul.hidden;ul.hidden=next;fold.setAttribute('aria-expanded',String(!next));fold.setAttribute('aria-label',(next?'Expand ':'Collapse ')+task.id);fold.textContent=next?'▸':'▾';if(next){collapsedTasks.add(key);expandedTasks.delete(key);}else{collapsedTasks.delete(key);expandedTasks.add(key);}});li.append(line,ul);}else li.append(line);const row=document.createElement('button');row.type='button';row.className='task-row';row.setAttribute('aria-current',String(task.id===selectedTaskId));row.title=task.id;row.addEventListener('click',()=>{const deselect=selectedTaskId===task.id;selectedTaskId=deselect?null:task.id;eventTaskId=deselect?null:task.id;rememberSelection();clearEvents();renderAll();void loadEventHistory();});const id=element('span','task-id',text(task.id));id.style.paddingLeft=Math.min(depth,4)*6+'px';row.append(id,element('span','badge '+value(task.state),text(task.state)));const tag=taskTagDisplay(selected(),task);if(tag){const badge=element('span','task-tag',text(tag));badge.title=(selected()?.tag?.description||'Task tag')+': '+task.tag;row.append(badge);}line.append(row);return li;}