@gh-symphony/cli 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,8 @@
1
1
  import { readFile, readdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { runCli as orchestratorRunCli } from "@gh-symphony/orchestrator";
4
- import { resolveRuntimeRoot, resolveTenantConfig, syncTenantToRuntime, } from "../orchestrator-runtime.js";
4
+ import { resolveRuntimeRoot, syncProjectToRuntime, } from "../orchestrator-runtime.js";
5
+ import { handleMissingManagedProjectConfig, resolveManagedProjectConfig, } from "../project-selection.js";
5
6
  function parseRecoverArgs(args) {
6
7
  const parsed = { dryRun: false };
7
8
  for (let i = 0; i < args.length; i += 1) {
@@ -9,8 +10,8 @@ function parseRecoverArgs(args) {
9
10
  if (arg === "--dry-run") {
10
11
  parsed.dryRun = true;
11
12
  }
12
- if (arg === "--tenant" || arg === "--tenant-id") {
13
- parsed.tenantId = args[i + 1];
13
+ if (arg === "--project" || arg === "--project-id") {
14
+ parsed.projectId = args[i + 1];
14
15
  i += 1;
15
16
  }
16
17
  }
@@ -18,18 +19,20 @@ function parseRecoverArgs(args) {
18
19
  }
19
20
  const handler = async (args, options) => {
20
21
  const parsed = parseRecoverArgs(args);
21
- const tenantConfig = await resolveTenantConfig(options.configDir, parsed.tenantId);
22
- if (!tenantConfig) {
23
- process.stderr.write("No tenant configured. Run 'gh-symphony init' first.\n");
24
- process.exitCode = 1;
22
+ const projectConfig = await resolveManagedProjectConfig({
23
+ configDir: options.configDir,
24
+ requestedProjectId: parsed.projectId,
25
+ });
26
+ if (!projectConfig) {
27
+ handleMissingManagedProjectConfig();
25
28
  return;
26
29
  }
27
30
  const runtimeRoot = resolveRuntimeRoot(options.configDir);
28
- const tenantId = tenantConfig.tenantId;
29
- await syncTenantToRuntime(options.configDir, tenantConfig);
31
+ const projectId = projectConfig.projectId;
32
+ await syncProjectToRuntime(options.configDir, projectConfig);
30
33
  if (parsed.dryRun) {
31
34
  process.stdout.write("Dry run — scanning for stalled runs...\n");
32
- const candidates = await listRecoverCandidates(runtimeRoot, tenantId);
35
+ const candidates = await listRecoverCandidates(runtimeRoot, projectId);
33
36
  if (options.json) {
34
37
  process.stdout.write(JSON.stringify(candidates, null, 2) + "\n");
35
38
  return;
@@ -48,12 +51,12 @@ const handler = async (args, options) => {
48
51
  "recover",
49
52
  "--runtime-root",
50
53
  runtimeRoot,
51
- "--tenant-id",
52
- tenantId,
54
+ "--project-id",
55
+ projectId,
53
56
  ]);
54
57
  };
55
58
  export default handler;
56
- async function listRecoverCandidates(runtimeRoot, tenantId) {
59
+ async function listRecoverCandidates(runtimeRoot, projectId) {
57
60
  const runsDir = join(runtimeRoot, "orchestrator", "runs");
58
61
  const candidates = [];
59
62
  let entries = [];
@@ -68,7 +71,7 @@ async function listRecoverCandidates(runtimeRoot, tenantId) {
68
71
  try {
69
72
  const raw = await readFile(runPath, "utf8");
70
73
  const run = JSON.parse(raw);
71
- if (run.tenantId !== tenantId) {
74
+ if (run.projectId !== projectId) {
72
75
  continue;
73
76
  }
74
77
  const reason = detectRecoveryReason(run);
@@ -1,4 +1,4 @@
1
- import { loadActiveTenantConfig, loadGlobalConfig, saveTenantConfig, } from "../config.js";
1
+ import { loadActiveProjectConfig, loadGlobalConfig, saveProjectConfig, } from "../config.js";
2
2
  const handler = async (args, options) => {
3
3
  const [subcommand, ...rest] = args;
4
4
  switch (subcommand) {
@@ -19,9 +19,9 @@ const handler = async (args, options) => {
19
19
  export default handler;
20
20
  // ── 6.4: repo list / add / remove ────────────────────────────────────────────
21
21
  async function repoList(options) {
22
- const ws = await loadActiveTenantConfig(options.configDir);
22
+ const ws = await loadActiveProjectConfig(options.configDir);
23
23
  if (!ws) {
24
- process.stderr.write("No tenant configured.\n");
24
+ process.stderr.write("No project configured.\n");
25
25
  process.exitCode = 1;
26
26
  return;
27
27
  }
@@ -42,14 +42,14 @@ async function repoAdd(args, options) {
42
42
  return;
43
43
  }
44
44
  const global = await loadGlobalConfig(options.configDir);
45
- if (!global?.activeTenant) {
46
- process.stderr.write("No active tenant.\n");
45
+ if (!global?.activeProject) {
46
+ process.stderr.write("No active project.\n");
47
47
  process.exitCode = 1;
48
48
  return;
49
49
  }
50
- const ws = await loadActiveTenantConfig(options.configDir);
50
+ const ws = await loadActiveProjectConfig(options.configDir);
51
51
  if (!ws) {
52
- process.stderr.write("Tenant config missing.\n");
52
+ process.stderr.write("Project config missing.\n");
53
53
  process.exitCode = 1;
54
54
  return;
55
55
  }
@@ -68,7 +68,7 @@ async function repoAdd(args, options) {
68
68
  name,
69
69
  cloneUrl: `https://github.com/${owner}/${name}.git`,
70
70
  });
71
- await saveTenantConfig(options.configDir, global.activeTenant, ws);
71
+ await saveProjectConfig(options.configDir, global.activeProject, ws);
72
72
  process.stdout.write(`Added repository: ${repoSpec}\n`);
73
73
  }
74
74
  async function repoRemove(args, options) {
@@ -79,14 +79,14 @@ async function repoRemove(args, options) {
79
79
  return;
80
80
  }
81
81
  const global = await loadGlobalConfig(options.configDir);
82
- if (!global?.activeTenant) {
83
- process.stderr.write("No active tenant.\n");
82
+ if (!global?.activeProject) {
83
+ process.stderr.write("No active project.\n");
84
84
  process.exitCode = 1;
85
85
  return;
86
86
  }
87
- const ws = await loadActiveTenantConfig(options.configDir);
87
+ const ws = await loadActiveProjectConfig(options.configDir);
88
88
  if (!ws) {
89
- process.stderr.write("Tenant config missing.\n");
89
+ process.stderr.write("Project config missing.\n");
90
90
  process.exitCode = 1;
91
91
  return;
92
92
  }
@@ -98,6 +98,6 @@ async function repoRemove(args, options) {
98
98
  return;
99
99
  }
100
100
  ws.repositories.splice(idx, 1);
101
- await saveTenantConfig(options.configDir, global.activeTenant, ws);
101
+ await saveProjectConfig(options.configDir, global.activeProject, ws);
102
102
  process.stdout.write(`Removed repository: ${repoSpec}\n`);
103
103
  }
@@ -1,5 +1,6 @@
1
1
  import { runCli as orchestratorRunCli } from "@gh-symphony/orchestrator";
2
- import { resolveRuntimeRoot, resolveTenantConfig, syncTenantToRuntime, } from "../orchestrator-runtime.js";
2
+ import { resolveRuntimeRoot, syncProjectToRuntime, } from "../orchestrator-runtime.js";
3
+ import { handleMissingManagedProjectConfig, resolveManagedProjectConfig, } from "../project-selection.js";
3
4
  function parseRunArgs(args) {
4
5
  const parsed = {
5
6
  watch: false,
@@ -9,8 +10,8 @@ function parseRunArgs(args) {
9
10
  if (arg === "--watch" || arg === "-w") {
10
11
  parsed.watch = true;
11
12
  }
12
- else if (arg === "--tenant" || arg === "--tenant-id") {
13
- parsed.tenantId = args[i + 1];
13
+ else if (arg === "--project" || arg === "--project-id") {
14
+ parsed.projectId = args[i + 1];
14
15
  i += 1;
15
16
  }
16
17
  else if (!arg?.startsWith("--")) {
@@ -27,21 +28,23 @@ const handler = async (args, options) => {
27
28
  process.exitCode = 2;
28
29
  return;
29
30
  }
30
- const tenantConfig = await resolveTenantConfig(options.configDir, parsed.tenantId);
31
- if (!tenantConfig) {
32
- process.stderr.write("No tenant configured. Run 'gh-symphony init' first.\n");
33
- process.exitCode = 1;
31
+ const projectConfig = await resolveManagedProjectConfig({
32
+ configDir: options.configDir,
33
+ requestedProjectId: parsed.projectId,
34
+ });
35
+ if (!projectConfig) {
36
+ handleMissingManagedProjectConfig();
34
37
  return;
35
38
  }
36
39
  const runtimeRoot = resolveRuntimeRoot(options.configDir);
37
- const tenantId = tenantConfig.tenantId;
38
- await syncTenantToRuntime(options.configDir, tenantConfig);
40
+ const projectId = projectConfig.projectId;
41
+ await syncProjectToRuntime(options.configDir, projectConfig);
39
42
  // Validate the issue identifier belongs to a configured repo
40
43
  const [repoSpec] = parsed.issue.split("#");
41
44
  if (repoSpec &&
42
- !tenantConfig.repositories.some((r) => `${r.owner}/${r.name}` === repoSpec)) {
43
- process.stderr.write(`Repository "${repoSpec}" is not configured in this tenant.\n` +
44
- `Configured repos: ${tenantConfig.repositories.map((r) => `${r.owner}/${r.name}`).join(", ")}\n`);
45
+ !projectConfig.repositories.some((r) => `${r.owner}/${r.name}` === repoSpec)) {
46
+ process.stderr.write(`Repository "${repoSpec}" is not configured in this project.\n` +
47
+ `Configured repos: ${projectConfig.repositories.map((r) => `${r.owner}/${r.name}`).join(", ")}\n`);
45
48
  process.exitCode = 1;
46
49
  return;
47
50
  }
@@ -50,8 +53,8 @@ const handler = async (args, options) => {
50
53
  "run-issue",
51
54
  "--runtime-root",
52
55
  runtimeRoot,
53
- "--tenant-id",
54
- tenantId,
56
+ "--project-id",
57
+ projectId,
55
58
  "--issue",
56
59
  parsed.issue,
57
60
  ]);
@@ -61,8 +64,8 @@ const handler = async (args, options) => {
61
64
  "status",
62
65
  "--runtime-root",
63
66
  runtimeRoot,
64
- "--tenant-id",
65
- tenantId,
67
+ "--project-id",
68
+ projectId,
66
69
  ]);
67
70
  }
68
71
  };
@@ -1,3 +1,14 @@
1
+ import { rm } from "node:fs/promises";
1
2
  import type { GlobalOptions } from "../index.js";
3
+ type ForegroundShutdownOptions = {
4
+ configDir: string;
5
+ projectId: string;
6
+ statusServer: {
7
+ close(): void;
8
+ };
9
+ exit?: (code?: number) => never;
10
+ removePortFile?: typeof rm;
11
+ };
2
12
  declare const handler: (args: string[], options: GlobalOptions) => Promise<void>;
13
+ export declare function shutdownForegroundOrchestrator(input: ForegroundShutdownOptions): Promise<never>;
3
14
  export default handler;
@@ -1,9 +1,11 @@
1
- import { writeFile, mkdir, readFile } from "node:fs/promises";
1
+ import { writeFile, mkdir, readFile, rm } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { spawn } from "node:child_process";
4
- import { daemonPidPath, orchestratorLogPath, logsDir } from "../config.js";
4
+ import { once } from "node:events";
5
+ import { daemonPidPath, orchestratorLogPath, orchestratorPortPath, } from "../config.js";
5
6
  import { OrchestratorService, createStore, startOrchestratorStatusServer, } from "@gh-symphony/orchestrator";
6
- import { resolveTenantConfig, resolveRuntimeRoot, syncTenantToRuntime, } from "../orchestrator-runtime.js";
7
+ import { resolveRuntimeRoot, syncProjectToRuntime, } from "../orchestrator-runtime.js";
8
+ import { handleMissingManagedProjectConfig, resolveManagedProjectConfig, } from "../project-selection.js";
7
9
  import { bold, dim, green, red, yellow, cyan, setNoColor } from "../ansi.js";
8
10
  import { getGhToken } from "../github/gh-auth.js";
9
11
  function timestamp() {
@@ -18,114 +20,122 @@ function logLine(icon, msg) {
18
20
  }
19
21
  // ── Arg parsing ───────────────────────────────────────────────────────────────
20
22
  function parseStartArgs(args) {
21
- const parsed = { daemon: false };
23
+ const parsed = {
24
+ daemon: false,
25
+ };
22
26
  for (let i = 0; i < args.length; i += 1) {
23
27
  const arg = args[i];
24
28
  if (arg === "--daemon" || arg === "-d") {
25
29
  parsed.daemon = true;
30
+ continue;
26
31
  }
27
- if (arg === "--tenant" || arg === "--tenant-id") {
28
- parsed.tenantId = args[i + 1];
32
+ if (arg === "--project" || arg === "--project-id") {
33
+ const value = args[i + 1];
34
+ if (!value || value.startsWith("-")) {
35
+ parsed.error = `Option '${arg}' argument missing`;
36
+ return parsed;
37
+ }
38
+ parsed.projectId = value;
29
39
  i += 1;
40
+ continue;
41
+ }
42
+ if (arg?.startsWith("-")) {
43
+ parsed.error = `Unknown option '${arg}'`;
44
+ return parsed;
30
45
  }
31
46
  }
32
47
  return parsed;
33
48
  }
34
49
  // ── Tick logging ──────────────────────────────────────────────────────────────
35
- function logTickResult(snapshots, prevSnapshots, isFirst) {
36
- for (const snap of snapshots) {
37
- const prev = prevSnapshots.find((p) => p.tenantId === snap.tenantId);
38
- if (isFirst) {
39
- const healthColor = snap.health === "degraded"
40
- ? red
41
- : snap.health === "running"
42
- ? green
43
- : cyan;
44
- logLine(green("\u25CF"), `Tenant ${bold(snap.slug)} connected ${dim("(")}${healthColor(snap.health)}${dim(")")}`);
45
- if (snap.summary.activeRuns > 0) {
46
- logLine(cyan("\u25B8"), `${snap.summary.activeRuns} active run(s)`);
47
- }
48
- continue;
49
- }
50
- // Health changes
51
- if (prev && prev.health !== snap.health) {
52
- const icon = snap.health === "degraded" ? red("\u25CF") : green("\u25CF");
53
- logLine(icon, `Health changed: ${prev.health} \u2192 ${bold(snap.health)}`);
54
- }
55
- // New error
56
- if (snap.lastError && snap.lastError !== prev?.lastError) {
57
- logLine(red("\u2717"), red(snap.lastError));
58
- }
59
- // Error cleared
60
- if (!snap.lastError && prev?.lastError) {
61
- logLine(green("\u2713"), green("Error cleared"));
62
- }
63
- // Dispatched delta
64
- const prevDispatched = prev?.summary.dispatched ?? 0;
65
- if (snap.summary.dispatched > prevDispatched) {
66
- const delta = snap.summary.dispatched - prevDispatched;
67
- logLine(yellow("\u25B8"), `Dispatched ${bold(String(delta))} new run(s)`);
68
- }
69
- // Active run changes
70
- const prevRunIds = new Set(prev?.activeRuns.map((r) => r.runId) ?? []);
71
- for (const run of snap.activeRuns) {
72
- if (!prevRunIds.has(run.runId)) {
73
- logLine(cyan("\u25B8"), `Run started: ${bold(run.issueIdentifier)} ${dim("state=")}${run.issueState} ${dim("status=")}${run.status}`);
74
- }
75
- }
76
- // Completed runs (were active, now gone)
77
- const currentRunIds = new Set(snap.activeRuns.map((r) => r.runId));
78
- for (const prevRun of prev?.activeRuns ?? []) {
79
- if (!currentRunIds.has(prevRun.runId)) {
80
- logLine(green("\u2713"), `Run finished: ${bold(prevRun.issueIdentifier)} ${dim("(")}${prevRun.status}${dim(")")}`);
81
- }
50
+ function logTickResult(snapshot, prevSnapshot, isFirst) {
51
+ if (isFirst) {
52
+ const healthColor = snapshot.health === "degraded"
53
+ ? red
54
+ : snapshot.health === "running"
55
+ ? green
56
+ : cyan;
57
+ logLine(green("\u25CF"), `Project ${bold(snapshot.slug)} connected ${dim("(")}${healthColor(snapshot.health)}${dim(")")}`);
58
+ if (snapshot.summary.activeRuns > 0) {
59
+ logLine(cyan("\u25B8"), `${snapshot.summary.activeRuns} active run(s)`);
82
60
  }
83
- // Suppressed delta
84
- const prevSuppressed = prev?.summary.suppressed ?? 0;
85
- if (snap.summary.suppressed > prevSuppressed) {
86
- const delta = snap.summary.suppressed - prevSuppressed;
87
- logLine(dim("\u25CB"), dim(`${delta} issue(s) suppressed (already running or at limit)`));
88
- }
89
- // Recovered delta
90
- const prevRecovered = prev?.summary.recovered ?? 0;
91
- if (snap.summary.recovered > prevRecovered) {
92
- const delta = snap.summary.recovered - prevRecovered;
93
- logLine(yellow("\u21BA"), `Recovered ${bold(String(delta))} stalled run(s)`);
94
- }
95
- // Retry queue changes
96
- const prevRetryCount = prev?.retryQueue.length ?? 0;
97
- if (snap.retryQueue.length > prevRetryCount) {
98
- const delta = snap.retryQueue.length - prevRetryCount;
99
- logLine(yellow("\u25CC"), `${delta} run(s) queued for retry`);
61
+ return;
62
+ }
63
+ if (prevSnapshot && prevSnapshot.health !== snapshot.health) {
64
+ const icon = snapshot.health === "degraded" ? red("\u25CF") : green("\u25CF");
65
+ logLine(icon, `Health changed: ${prevSnapshot.health} \u2192 ${bold(snapshot.health)}`);
66
+ }
67
+ if (snapshot.lastError && snapshot.lastError !== prevSnapshot?.lastError) {
68
+ logLine(red("\u2717"), red(snapshot.lastError));
69
+ }
70
+ if (!snapshot.lastError && prevSnapshot?.lastError) {
71
+ logLine(green("\u2713"), green("Error cleared"));
72
+ }
73
+ const prevDispatched = prevSnapshot?.summary.dispatched ?? 0;
74
+ if (snapshot.summary.dispatched > prevDispatched) {
75
+ const delta = snapshot.summary.dispatched - prevDispatched;
76
+ logLine(yellow("\u25B8"), `Dispatched ${bold(String(delta))} new run(s)`);
77
+ }
78
+ const prevRunIds = new Set(prevSnapshot?.activeRuns.map((run) => run.runId) ?? []);
79
+ for (const run of snapshot.activeRuns) {
80
+ if (!prevRunIds.has(run.runId)) {
81
+ logLine(cyan("\u25B8"), `Run started: ${bold(run.issueIdentifier)} ${dim("state=")}${run.issueState} ${dim("status=")}${run.status}`);
100
82
  }
101
- // Quiet tick — no changes
102
- const changed = snap.health !== prev?.health ||
103
- snap.lastError !== prev?.lastError ||
104
- snap.summary.dispatched !== prev?.summary.dispatched ||
105
- snap.summary.suppressed !== prev?.summary.suppressed ||
106
- snap.summary.recovered !== prev?.summary.recovered ||
107
- snap.activeRuns.length !== (prev?.activeRuns.length ?? 0) ||
108
- snap.retryQueue.length !== (prev?.retryQueue.length ?? 0);
109
- if (!changed) {
110
- logLine(dim("\u00B7"), dim(`tick \u2014 ${snap.summary.activeRuns} active, ${snap.health}`));
83
+ }
84
+ const currentRunIds = new Set(snapshot.activeRuns.map((run) => run.runId));
85
+ for (const prevRun of prevSnapshot?.activeRuns ?? []) {
86
+ if (!currentRunIds.has(prevRun.runId)) {
87
+ logLine(green("\u2713"), `Run finished: ${bold(prevRun.issueIdentifier)} ${dim("(")}${prevRun.status}${dim(")")}`);
111
88
  }
112
89
  }
90
+ const prevSuppressed = prevSnapshot?.summary.suppressed ?? 0;
91
+ if (snapshot.summary.suppressed > prevSuppressed) {
92
+ const delta = snapshot.summary.suppressed - prevSuppressed;
93
+ logLine(dim("\u25CB"), dim(`${delta} issue(s) suppressed (already running or at limit)`));
94
+ }
95
+ const prevRecovered = prevSnapshot?.summary.recovered ?? 0;
96
+ if (snapshot.summary.recovered > prevRecovered) {
97
+ const delta = snapshot.summary.recovered - prevRecovered;
98
+ logLine(yellow("\u21BA"), `Recovered ${bold(String(delta))} stalled run(s)`);
99
+ }
100
+ const prevRetryCount = prevSnapshot?.retryQueue.length ?? 0;
101
+ if (snapshot.retryQueue.length > prevRetryCount) {
102
+ const delta = snapshot.retryQueue.length - prevRetryCount;
103
+ logLine(yellow("\u25CC"), `${delta} run(s) queued for retry`);
104
+ }
105
+ const changed = snapshot.health !== prevSnapshot?.health ||
106
+ snapshot.lastError !== prevSnapshot?.lastError ||
107
+ snapshot.summary.dispatched !== prevSnapshot?.summary.dispatched ||
108
+ snapshot.summary.suppressed !== prevSnapshot?.summary.suppressed ||
109
+ snapshot.summary.recovered !== prevSnapshot?.summary.recovered ||
110
+ snapshot.activeRuns.length !== (prevSnapshot?.activeRuns.length ?? 0) ||
111
+ snapshot.retryQueue.length !== (prevSnapshot?.retryQueue.length ?? 0);
112
+ if (!changed) {
113
+ logLine(dim("\u00B7"), dim(`tick \u2014 ${snapshot.summary.activeRuns} active, ${snapshot.health}`));
114
+ }
113
115
  }
114
116
  // ── Handler ───────────────────────────────────────────────────────────────────
115
117
  const handler = async (args, options) => {
116
118
  setNoColor(options.noColor);
117
119
  const parsed = parseStartArgs(args);
118
- const tenantConfig = await resolveTenantConfig(options.configDir, parsed.tenantId);
119
- if (!tenantConfig) {
120
- process.stderr.write("No tenant configured. Run 'gh-symphony init' first.\n");
121
- process.exitCode = 1;
120
+ if (parsed.error) {
121
+ process.stderr.write(`${parsed.error}\n`);
122
+ process.stderr.write("Usage: gh-symphony start --project-id <project-id> [--daemon]\n");
123
+ process.exitCode = 2;
124
+ return;
125
+ }
126
+ const projectConfig = await resolveManagedProjectConfig({
127
+ configDir: options.configDir,
128
+ requestedProjectId: parsed.projectId,
129
+ });
130
+ if (!projectConfig) {
131
+ handleMissingManagedProjectConfig();
122
132
  return;
123
133
  }
124
134
  const runtimeRoot = resolveRuntimeRoot(options.configDir);
125
- const tenantId = tenantConfig.tenantId;
126
- await syncTenantToRuntime(options.configDir, tenantConfig);
135
+ const projectId = projectConfig.projectId;
136
+ await syncProjectToRuntime(options.configDir, projectConfig);
127
137
  if (parsed.daemon) {
128
- await startDaemon(options, tenantId);
138
+ await startDaemon(options, projectId);
129
139
  return;
130
140
  }
131
141
  // ── 5.1: Foreground mode with live logging ────────────────────────────────
@@ -139,50 +149,53 @@ const handler = async (args, options) => {
139
149
  }
140
150
  }
141
151
  const store = createStore(runtimeRoot);
142
- const service = new OrchestratorService(store);
143
- // Start status server
144
- startOrchestratorStatusServer({
152
+ const service = new OrchestratorService(store, projectConfig);
153
+ const statusServer = startOrchestratorStatusServer({
145
154
  host: "127.0.0.1",
146
- port: 4680,
147
- getTenantStatus: {
148
- all: () => service.status(),
149
- byTenantId: async (id) => {
150
- const [snapshot] = await service.status(id);
151
- return snapshot ?? null;
152
- },
153
- },
155
+ port: 0,
156
+ getProjectStatus: () => service.status(),
154
157
  onRefresh: async () => {
155
- await service.runOnce({ tenantId });
158
+ await service.runOnce();
156
159
  },
157
160
  });
158
- logLine(green("\u25B2"), `Starting orchestrator for tenant: ${bold(tenantId)}`);
161
+ await persistStatusServerPort(options.configDir, projectId, statusServer);
162
+ logLine(green("\u25B2"), `Starting orchestrator for project: ${bold(projectId)}`);
159
163
  logLine(dim("\u00B7"), dim("Press Ctrl+C to stop"));
160
164
  let running = true;
161
- const shutdown = () => {
165
+ let shuttingDown = false;
166
+ const shutdown = async () => {
167
+ if (shuttingDown) {
168
+ return;
169
+ }
170
+ shuttingDown = true;
162
171
  running = false;
163
- logLine(yellow("\u25BC"), "Shutting down...");
164
- process.exit(0);
172
+ await shutdownForegroundOrchestrator({
173
+ configDir: options.configDir,
174
+ projectId,
175
+ statusServer,
176
+ });
165
177
  };
166
- process.on("SIGINT", shutdown);
167
- process.on("SIGTERM", shutdown);
168
- let prevSnapshots = [];
178
+ process.on("SIGINT", () => {
179
+ void shutdown();
180
+ });
181
+ process.on("SIGTERM", () => {
182
+ void shutdown();
183
+ });
184
+ let prevSnapshot = null;
169
185
  let isFirst = true;
170
186
  while (running) {
171
187
  try {
172
- const snapshots = await service.runOnce({ tenantId });
173
- logTickResult(snapshots, prevSnapshots, isFirst);
188
+ const snapshot = await service.runOnce();
189
+ logTickResult(snapshot, prevSnapshot, isFirst);
174
190
  if (!isFirst) {
175
- for (const snap of snapshots) {
176
- const prev = prevSnapshots.find((p) => p.tenantId === snap.tenantId);
177
- const currentRunIds = new Set(snap.activeRuns.map((r) => r.runId));
178
- for (const prevRun of prev?.activeRuns ?? []) {
179
- if (!currentRunIds.has(prevRun.runId)) {
180
- await tailWorkerLog(runtimeRoot, prevRun.runId, prevRun.issueIdentifier);
181
- }
191
+ const currentRunIds = new Set(snapshot.activeRuns.map((run) => run.runId));
192
+ for (const prevRun of prevSnapshot?.activeRuns ?? []) {
193
+ if (!currentRunIds.has(prevRun.runId)) {
194
+ await tailWorkerLog(runtimeRoot, prevRun.runId, prevRun.issueIdentifier);
182
195
  }
183
196
  }
184
197
  }
185
- prevSnapshots = snapshots;
198
+ prevSnapshot = snapshot;
186
199
  isFirst = false;
187
200
  }
188
201
  catch (error) {
@@ -192,6 +205,24 @@ const handler = async (args, options) => {
192
205
  await new Promise((r) => setTimeout(r, 30_000));
193
206
  }
194
207
  };
208
+ export async function shutdownForegroundOrchestrator(input) {
209
+ logLine(yellow("\u25BC"), "Shutting down...");
210
+ try {
211
+ input.statusServer.close();
212
+ }
213
+ catch (error) {
214
+ logLine(red("\u2717"), red(`Failed to close status server: ${error instanceof Error ? error.message : "Unknown error"}`));
215
+ }
216
+ try {
217
+ await (input.removePortFile ?? rm)(orchestratorPortPath(input.configDir, input.projectId), {
218
+ force: true,
219
+ });
220
+ }
221
+ catch (error) {
222
+ logLine(yellow("\u26A0"), `Failed to remove persisted status port: ${error instanceof Error ? error.message : "Unknown error"}`);
223
+ }
224
+ return (input.exit ?? process.exit)(0);
225
+ }
195
226
  async function tailWorkerLog(runtimeRoot, runId, issueIdentifier) {
196
227
  try {
197
228
  const logPath = join(runtimeRoot, "orchestrator", "runs", runId, "worker.log");
@@ -211,12 +242,12 @@ async function tailWorkerLog(runtimeRoot, runId, issueIdentifier) {
211
242
  }
212
243
  export default handler;
213
244
  // ── 5.2: Daemon mode ─────────────────────────────────────────────────────────
214
- async function startDaemon(options, tenantId) {
215
- const logPath = orchestratorLogPath(options.configDir);
216
- await mkdir(logsDir(options.configDir), { recursive: true });
245
+ async function startDaemon(options, projectId) {
246
+ const logPath = orchestratorLogPath(options.configDir, projectId);
247
+ await mkdir(dirname(logPath), { recursive: true });
217
248
  const { openSync } = await import("node:fs");
218
249
  const logFd = openSync(logPath, "a");
219
- const child = spawn(process.execPath, [process.argv[1], "start", "--tenant", tenantId], {
250
+ const child = spawn(process.execPath, [process.argv[1], "start", "--project", projectId], {
220
251
  cwd: process.cwd(),
221
252
  env: {
222
253
  ...process.env,
@@ -225,7 +256,7 @@ async function startDaemon(options, tenantId) {
225
256
  detached: true,
226
257
  stdio: ["ignore", logFd, logFd],
227
258
  });
228
- const pidPath = daemonPidPath(options.configDir);
259
+ const pidPath = daemonPidPath(options.configDir, projectId);
229
260
  await mkdir(dirname(pidPath), { recursive: true });
230
261
  await writeFile(pidPath, String(child.pid), "utf8");
231
262
  child.unref();
@@ -233,5 +264,17 @@ async function startDaemon(options, tenantId) {
233
264
  closeSync(logFd);
234
265
  process.stdout.write(`Orchestrator started in background (PID: ${child.pid}).\n` +
235
266
  `Logs: ${logPath}\n` +
236
- `Stop with: gh-symphony stop\n`);
267
+ `Stop with: gh-symphony project stop --project-id ${projectId}\n`);
268
+ }
269
+ async function persistStatusServerPort(configDir, projectId, statusServer) {
270
+ if (!statusServer.listening) {
271
+ await once(statusServer, "listening");
272
+ }
273
+ const address = statusServer.address();
274
+ if (!address || typeof address !== "object") {
275
+ return;
276
+ }
277
+ const portPath = orchestratorPortPath(configDir, projectId);
278
+ await mkdir(dirname(portPath), { recursive: true });
279
+ await writeFile(portPath, `${address.port}\n`, "utf8");
237
280
  }
@@ -2,6 +2,7 @@ type RefreshRequestOptions = {
2
2
  fetchImpl?: typeof fetch;
3
3
  timeoutMs?: number;
4
4
  env?: NodeJS.ProcessEnv;
5
+ baseUrl?: string | null;
5
6
  };
6
7
  export declare function resolveOrchestratorStatusBaseUrl(env?: NodeJS.ProcessEnv): string;
7
8
  export declare function requestOrchestratorRefresh(options?: RefreshRequestOptions): Promise<boolean>;
@@ -8,8 +8,14 @@ export async function requestOrchestratorRefresh(options = {}) {
8
8
  const fetchImpl = options.fetchImpl ?? fetch;
9
9
  const timeoutMs = options.timeoutMs ?? 5_000;
10
10
  const signal = AbortSignal.timeout(timeoutMs);
11
+ const baseUrl = "baseUrl" in options
12
+ ? options.baseUrl
13
+ : resolveOrchestratorStatusBaseUrl(options.env);
14
+ if (!baseUrl) {
15
+ return false;
16
+ }
11
17
  try {
12
- const response = await fetchImpl(`${resolveOrchestratorStatusBaseUrl(options.env)}/api/v1/refresh`, {
18
+ const response = await fetchImpl(`${baseUrl}/api/v1/refresh`, {
13
19
  method: "POST",
14
20
  signal,
15
21
  });