@gh-symphony/cli 0.0.6 → 0.0.7

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,7 @@
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, resolveProjectConfig, syncProjectToRuntime, } from "../orchestrator-runtime.js";
5
5
  function parseRecoverArgs(args) {
6
6
  const parsed = { dryRun: false };
7
7
  for (let i = 0; i < args.length; i += 1) {
@@ -9,8 +9,8 @@ function parseRecoverArgs(args) {
9
9
  if (arg === "--dry-run") {
10
10
  parsed.dryRun = true;
11
11
  }
12
- if (arg === "--tenant" || arg === "--tenant-id") {
13
- parsed.tenantId = args[i + 1];
12
+ if (arg === "--project" || arg === "--project-id") {
13
+ parsed.projectId = args[i + 1];
14
14
  i += 1;
15
15
  }
16
16
  }
@@ -18,18 +18,18 @@ function parseRecoverArgs(args) {
18
18
  }
19
19
  const handler = async (args, options) => {
20
20
  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");
21
+ const projectConfig = await resolveProjectConfig(options.configDir, parsed.projectId);
22
+ if (!projectConfig) {
23
+ process.stderr.write("No project configured. Run 'gh-symphony project add' first.\n");
24
24
  process.exitCode = 1;
25
25
  return;
26
26
  }
27
27
  const runtimeRoot = resolveRuntimeRoot(options.configDir);
28
- const tenantId = tenantConfig.tenantId;
29
- await syncTenantToRuntime(options.configDir, tenantConfig);
28
+ const projectId = projectConfig.projectId;
29
+ await syncProjectToRuntime(options.configDir, projectConfig);
30
30
  if (parsed.dryRun) {
31
31
  process.stdout.write("Dry run — scanning for stalled runs...\n");
32
- const candidates = await listRecoverCandidates(runtimeRoot, tenantId);
32
+ const candidates = await listRecoverCandidates(runtimeRoot, projectId);
33
33
  if (options.json) {
34
34
  process.stdout.write(JSON.stringify(candidates, null, 2) + "\n");
35
35
  return;
@@ -48,12 +48,12 @@ const handler = async (args, options) => {
48
48
  "recover",
49
49
  "--runtime-root",
50
50
  runtimeRoot,
51
- "--tenant-id",
52
- tenantId,
51
+ "--project-id",
52
+ projectId,
53
53
  ]);
54
54
  };
55
55
  export default handler;
56
- async function listRecoverCandidates(runtimeRoot, tenantId) {
56
+ async function listRecoverCandidates(runtimeRoot, projectId) {
57
57
  const runsDir = join(runtimeRoot, "orchestrator", "runs");
58
58
  const candidates = [];
59
59
  let entries = [];
@@ -68,7 +68,7 @@ async function listRecoverCandidates(runtimeRoot, tenantId) {
68
68
  try {
69
69
  const raw = await readFile(runPath, "utf8");
70
70
  const run = JSON.parse(raw);
71
- if (run.tenantId !== tenantId) {
71
+ if (run.projectId !== projectId) {
72
72
  continue;
73
73
  }
74
74
  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,5 @@
1
1
  import { runCli as orchestratorRunCli } from "@gh-symphony/orchestrator";
2
- import { resolveRuntimeRoot, resolveTenantConfig, syncTenantToRuntime, } from "../orchestrator-runtime.js";
2
+ import { resolveRuntimeRoot, resolveProjectConfig, syncProjectToRuntime, } from "../orchestrator-runtime.js";
3
3
  function parseRunArgs(args) {
4
4
  const parsed = {
5
5
  watch: false,
@@ -9,8 +9,8 @@ function parseRunArgs(args) {
9
9
  if (arg === "--watch" || arg === "-w") {
10
10
  parsed.watch = true;
11
11
  }
12
- else if (arg === "--tenant" || arg === "--tenant-id") {
13
- parsed.tenantId = args[i + 1];
12
+ else if (arg === "--project" || arg === "--project-id") {
13
+ parsed.projectId = args[i + 1];
14
14
  i += 1;
15
15
  }
16
16
  else if (!arg?.startsWith("--")) {
@@ -27,21 +27,21 @@ const handler = async (args, options) => {
27
27
  process.exitCode = 2;
28
28
  return;
29
29
  }
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");
30
+ const projectConfig = await resolveProjectConfig(options.configDir, parsed.projectId);
31
+ if (!projectConfig) {
32
+ process.stderr.write("No project configured. Run 'gh-symphony project add' first.\n");
33
33
  process.exitCode = 1;
34
34
  return;
35
35
  }
36
36
  const runtimeRoot = resolveRuntimeRoot(options.configDir);
37
- const tenantId = tenantConfig.tenantId;
38
- await syncTenantToRuntime(options.configDir, tenantConfig);
37
+ const projectId = projectConfig.projectId;
38
+ await syncProjectToRuntime(options.configDir, projectConfig);
39
39
  // Validate the issue identifier belongs to a configured repo
40
40
  const [repoSpec] = parsed.issue.split("#");
41
41
  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`);
42
+ !projectConfig.repositories.some((r) => `${r.owner}/${r.name}` === repoSpec)) {
43
+ process.stderr.write(`Repository "${repoSpec}" is not configured in this project.\n` +
44
+ `Configured repos: ${projectConfig.repositories.map((r) => `${r.owner}/${r.name}`).join(", ")}\n`);
45
45
  process.exitCode = 1;
46
46
  return;
47
47
  }
@@ -50,8 +50,8 @@ const handler = async (args, options) => {
50
50
  "run-issue",
51
51
  "--runtime-root",
52
52
  runtimeRoot,
53
- "--tenant-id",
54
- tenantId,
53
+ "--project-id",
54
+ projectId,
55
55
  "--issue",
56
56
  parsed.issue,
57
57
  ]);
@@ -61,8 +61,8 @@ const handler = async (args, options) => {
61
61
  "status",
62
62
  "--runtime-root",
63
63
  runtimeRoot,
64
- "--tenant-id",
65
- tenantId,
64
+ "--project-id",
65
+ projectId,
66
66
  ]);
67
67
  }
68
68
  };
@@ -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 { parseCliArgs } from "./parse-cli-args.js";
6
+ import { daemonPidPath, orchestratorLogPath, orchestratorPortPath, } from "../config.js";
5
7
  import { OrchestratorService, createStore, startOrchestratorStatusServer, } from "@gh-symphony/orchestrator";
6
- import { resolveTenantConfig, resolveRuntimeRoot, syncTenantToRuntime, } from "../orchestrator-runtime.js";
8
+ import { resolveProjectConfig, resolveRuntimeRoot, syncProjectToRuntime, } from "../orchestrator-runtime.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,112 @@ function logLine(icon, msg) {
18
20
  }
19
21
  // ── Arg parsing ───────────────────────────────────────────────────────────────
20
22
  function parseStartArgs(args) {
21
- const parsed = { daemon: false };
22
- for (let i = 0; i < args.length; i += 1) {
23
- const arg = args[i];
24
- if (arg === "--daemon" || arg === "-d") {
25
- parsed.daemon = true;
26
- }
27
- if (arg === "--tenant" || arg === "--tenant-id") {
28
- parsed.tenantId = args[i + 1];
29
- i += 1;
30
- }
23
+ const parsed = parseCliArgs(args, {
24
+ daemon: { type: "boolean", short: "d" },
25
+ project: { type: "string" },
26
+ "project-id": { type: "string" },
27
+ });
28
+ if ("error" in parsed) {
29
+ return { daemon: false, error: parsed.error };
31
30
  }
32
- return parsed;
31
+ return {
32
+ daemon: Boolean(parsed.values.daemon),
33
+ projectId: (parsed.values["project-id"] ?? parsed.values.project),
34
+ };
33
35
  }
34
36
  // ── 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)}`);
37
+ function logTickResult(snapshot, prevSnapshot, isFirst) {
38
+ if (isFirst) {
39
+ const healthColor = snapshot.health === "degraded"
40
+ ? red
41
+ : snapshot.health === "running"
42
+ ? green
43
+ : cyan;
44
+ logLine(green("\u25CF"), `Project ${bold(snapshot.slug)} connected ${dim("(")}${healthColor(snapshot.health)}${dim(")")}`);
45
+ if (snapshot.summary.activeRuns > 0) {
46
+ logLine(cyan("\u25B8"), `${snapshot.summary.activeRuns} active run(s)`);
54
47
  }
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
- }
82
- }
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`);
48
+ return;
49
+ }
50
+ if (prevSnapshot && prevSnapshot.health !== snapshot.health) {
51
+ const icon = snapshot.health === "degraded" ? red("\u25CF") : green("\u25CF");
52
+ logLine(icon, `Health changed: ${prevSnapshot.health} \u2192 ${bold(snapshot.health)}`);
53
+ }
54
+ if (snapshot.lastError && snapshot.lastError !== prevSnapshot?.lastError) {
55
+ logLine(red("\u2717"), red(snapshot.lastError));
56
+ }
57
+ if (!snapshot.lastError && prevSnapshot?.lastError) {
58
+ logLine(green("\u2713"), green("Error cleared"));
59
+ }
60
+ const prevDispatched = prevSnapshot?.summary.dispatched ?? 0;
61
+ if (snapshot.summary.dispatched > prevDispatched) {
62
+ const delta = snapshot.summary.dispatched - prevDispatched;
63
+ logLine(yellow("\u25B8"), `Dispatched ${bold(String(delta))} new run(s)`);
64
+ }
65
+ const prevRunIds = new Set(prevSnapshot?.activeRuns.map((run) => run.runId) ?? []);
66
+ for (const run of snapshot.activeRuns) {
67
+ if (!prevRunIds.has(run.runId)) {
68
+ logLine(cyan("\u25B8"), `Run started: ${bold(run.issueIdentifier)} ${dim("state=")}${run.issueState} ${dim("status=")}${run.status}`);
100
69
  }
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}`));
70
+ }
71
+ const currentRunIds = new Set(snapshot.activeRuns.map((run) => run.runId));
72
+ for (const prevRun of prevSnapshot?.activeRuns ?? []) {
73
+ if (!currentRunIds.has(prevRun.runId)) {
74
+ logLine(green("\u2713"), `Run finished: ${bold(prevRun.issueIdentifier)} ${dim("(")}${prevRun.status}${dim(")")}`);
111
75
  }
112
76
  }
77
+ const prevSuppressed = prevSnapshot?.summary.suppressed ?? 0;
78
+ if (snapshot.summary.suppressed > prevSuppressed) {
79
+ const delta = snapshot.summary.suppressed - prevSuppressed;
80
+ logLine(dim("\u25CB"), dim(`${delta} issue(s) suppressed (already running or at limit)`));
81
+ }
82
+ const prevRecovered = prevSnapshot?.summary.recovered ?? 0;
83
+ if (snapshot.summary.recovered > prevRecovered) {
84
+ const delta = snapshot.summary.recovered - prevRecovered;
85
+ logLine(yellow("\u21BA"), `Recovered ${bold(String(delta))} stalled run(s)`);
86
+ }
87
+ const prevRetryCount = prevSnapshot?.retryQueue.length ?? 0;
88
+ if (snapshot.retryQueue.length > prevRetryCount) {
89
+ const delta = snapshot.retryQueue.length - prevRetryCount;
90
+ logLine(yellow("\u25CC"), `${delta} run(s) queued for retry`);
91
+ }
92
+ const changed = snapshot.health !== prevSnapshot?.health ||
93
+ snapshot.lastError !== prevSnapshot?.lastError ||
94
+ snapshot.summary.dispatched !== prevSnapshot?.summary.dispatched ||
95
+ snapshot.summary.suppressed !== prevSnapshot?.summary.suppressed ||
96
+ snapshot.summary.recovered !== prevSnapshot?.summary.recovered ||
97
+ snapshot.activeRuns.length !== (prevSnapshot?.activeRuns.length ?? 0) ||
98
+ snapshot.retryQueue.length !== (prevSnapshot?.retryQueue.length ?? 0);
99
+ if (!changed) {
100
+ logLine(dim("\u00B7"), dim(`tick \u2014 ${snapshot.summary.activeRuns} active, ${snapshot.health}`));
101
+ }
113
102
  }
114
103
  // ── Handler ───────────────────────────────────────────────────────────────────
115
104
  const handler = async (args, options) => {
116
105
  setNoColor(options.noColor);
117
106
  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");
107
+ if (parsed.error) {
108
+ process.stderr.write(`${parsed.error}\n`);
109
+ process.stderr.write("Usage: gh-symphony start --project-id <project-id> [--daemon]\n");
110
+ process.exitCode = 2;
111
+ return;
112
+ }
113
+ if (!parsed.projectId) {
114
+ process.stderr.write("Usage: gh-symphony start --project-id <project-id> [--daemon]\n");
115
+ process.exitCode = 2;
116
+ return;
117
+ }
118
+ const projectConfig = await resolveProjectConfig(options.configDir, parsed.projectId);
119
+ if (!projectConfig) {
120
+ process.stderr.write("No project configured. Run 'gh-symphony project add' first.\n");
121
121
  process.exitCode = 1;
122
122
  return;
123
123
  }
124
124
  const runtimeRoot = resolveRuntimeRoot(options.configDir);
125
- const tenantId = tenantConfig.tenantId;
126
- await syncTenantToRuntime(options.configDir, tenantConfig);
125
+ const projectId = projectConfig.projectId;
126
+ await syncProjectToRuntime(options.configDir, projectConfig);
127
127
  if (parsed.daemon) {
128
- await startDaemon(options, tenantId);
128
+ await startDaemon(options, projectId);
129
129
  return;
130
130
  }
131
131
  // ── 5.1: Foreground mode with live logging ────────────────────────────────
@@ -139,50 +139,53 @@ const handler = async (args, options) => {
139
139
  }
140
140
  }
141
141
  const store = createStore(runtimeRoot);
142
- const service = new OrchestratorService(store);
143
- // Start status server
144
- startOrchestratorStatusServer({
142
+ const service = new OrchestratorService(store, projectConfig);
143
+ const statusServer = startOrchestratorStatusServer({
145
144
  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
- },
145
+ port: 0,
146
+ getProjectStatus: () => service.status(),
154
147
  onRefresh: async () => {
155
- await service.runOnce({ tenantId });
148
+ await service.runOnce();
156
149
  },
157
150
  });
158
- logLine(green("\u25B2"), `Starting orchestrator for tenant: ${bold(tenantId)}`);
151
+ await persistStatusServerPort(options.configDir, projectId, statusServer);
152
+ logLine(green("\u25B2"), `Starting orchestrator for project: ${bold(projectId)}`);
159
153
  logLine(dim("\u00B7"), dim("Press Ctrl+C to stop"));
160
154
  let running = true;
161
- const shutdown = () => {
155
+ let shuttingDown = false;
156
+ const shutdown = async () => {
157
+ if (shuttingDown) {
158
+ return;
159
+ }
160
+ shuttingDown = true;
162
161
  running = false;
163
- logLine(yellow("\u25BC"), "Shutting down...");
164
- process.exit(0);
162
+ await shutdownForegroundOrchestrator({
163
+ configDir: options.configDir,
164
+ projectId,
165
+ statusServer,
166
+ });
165
167
  };
166
- process.on("SIGINT", shutdown);
167
- process.on("SIGTERM", shutdown);
168
- let prevSnapshots = [];
168
+ process.on("SIGINT", () => {
169
+ void shutdown();
170
+ });
171
+ process.on("SIGTERM", () => {
172
+ void shutdown();
173
+ });
174
+ let prevSnapshot = null;
169
175
  let isFirst = true;
170
176
  while (running) {
171
177
  try {
172
- const snapshots = await service.runOnce({ tenantId });
173
- logTickResult(snapshots, prevSnapshots, isFirst);
178
+ const snapshot = await service.runOnce();
179
+ logTickResult(snapshot, prevSnapshot, isFirst);
174
180
  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
- }
181
+ const currentRunIds = new Set(snapshot.activeRuns.map((run) => run.runId));
182
+ for (const prevRun of prevSnapshot?.activeRuns ?? []) {
183
+ if (!currentRunIds.has(prevRun.runId)) {
184
+ await tailWorkerLog(runtimeRoot, prevRun.runId, prevRun.issueIdentifier);
182
185
  }
183
186
  }
184
187
  }
185
- prevSnapshots = snapshots;
188
+ prevSnapshot = snapshot;
186
189
  isFirst = false;
187
190
  }
188
191
  catch (error) {
@@ -192,6 +195,24 @@ const handler = async (args, options) => {
192
195
  await new Promise((r) => setTimeout(r, 30_000));
193
196
  }
194
197
  };
198
+ export async function shutdownForegroundOrchestrator(input) {
199
+ logLine(yellow("\u25BC"), "Shutting down...");
200
+ try {
201
+ input.statusServer.close();
202
+ }
203
+ catch (error) {
204
+ logLine(red("\u2717"), red(`Failed to close status server: ${error instanceof Error ? error.message : "Unknown error"}`));
205
+ }
206
+ try {
207
+ await (input.removePortFile ?? rm)(orchestratorPortPath(input.configDir, input.projectId), {
208
+ force: true,
209
+ });
210
+ }
211
+ catch (error) {
212
+ logLine(yellow("\u26A0"), `Failed to remove persisted status port: ${error instanceof Error ? error.message : "Unknown error"}`);
213
+ }
214
+ return (input.exit ?? process.exit)(0);
215
+ }
195
216
  async function tailWorkerLog(runtimeRoot, runId, issueIdentifier) {
196
217
  try {
197
218
  const logPath = join(runtimeRoot, "orchestrator", "runs", runId, "worker.log");
@@ -211,12 +232,12 @@ async function tailWorkerLog(runtimeRoot, runId, issueIdentifier) {
211
232
  }
212
233
  export default handler;
213
234
  // ── 5.2: Daemon mode ─────────────────────────────────────────────────────────
214
- async function startDaemon(options, tenantId) {
215
- const logPath = orchestratorLogPath(options.configDir);
216
- await mkdir(logsDir(options.configDir), { recursive: true });
235
+ async function startDaemon(options, projectId) {
236
+ const logPath = orchestratorLogPath(options.configDir, projectId);
237
+ await mkdir(dirname(logPath), { recursive: true });
217
238
  const { openSync } = await import("node:fs");
218
239
  const logFd = openSync(logPath, "a");
219
- const child = spawn(process.execPath, [process.argv[1], "start", "--tenant", tenantId], {
240
+ const child = spawn(process.execPath, [process.argv[1], "start", "--project", projectId], {
220
241
  cwd: process.cwd(),
221
242
  env: {
222
243
  ...process.env,
@@ -225,7 +246,7 @@ async function startDaemon(options, tenantId) {
225
246
  detached: true,
226
247
  stdio: ["ignore", logFd, logFd],
227
248
  });
228
- const pidPath = daemonPidPath(options.configDir);
249
+ const pidPath = daemonPidPath(options.configDir, projectId);
229
250
  await mkdir(dirname(pidPath), { recursive: true });
230
251
  await writeFile(pidPath, String(child.pid), "utf8");
231
252
  child.unref();
@@ -233,5 +254,17 @@ async function startDaemon(options, tenantId) {
233
254
  closeSync(logFd);
234
255
  process.stdout.write(`Orchestrator started in background (PID: ${child.pid}).\n` +
235
256
  `Logs: ${logPath}\n` +
236
- `Stop with: gh-symphony stop\n`);
257
+ `Stop with: gh-symphony project stop --project-id ${projectId}\n`);
258
+ }
259
+ async function persistStatusServerPort(configDir, projectId, statusServer) {
260
+ if (!statusServer.listening) {
261
+ await once(statusServer, "listening");
262
+ }
263
+ const address = statusServer.address();
264
+ if (!address || typeof address !== "object") {
265
+ return;
266
+ }
267
+ const portPath = orchestratorPortPath(configDir, projectId);
268
+ await mkdir(dirname(portPath), { recursive: true });
269
+ await writeFile(portPath, `${address.port}\n`, "utf8");
237
270
  }
@@ -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
  });