ework-aio 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-aio",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "All-in-one installer for ework (issue tracker) + ework-daemon (AI bridge) + opencode-ework (plugin). One command: npm i -g ework-aio && ework-aio install.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "ework-daemon": "^0.4.1",
50
- "ework-web": "^0.7.0",
50
+ "ework-web": "^0.8.0",
51
51
  "opencode-ework": "^0.1.0",
52
52
  "zod": "^3.23.0"
53
53
  },
@@ -709,6 +709,127 @@ case "$DELIVERIES_CODE" in
709
709
  ;;
710
710
  esac
711
711
 
712
+ # Phase 7: multi-daemon coordination (requires MySQL shared DB)
713
+ # -----------------------------------------------------------------------------
714
+ if grep -q 'WORK_DB_DRIVER=mysql' "$DATA_DIR/ework-daemon/.env" 2>/dev/null; then
715
+ echo
716
+ echo "====================================================="
717
+ echo "Phase 7: multi-daemon coordination"
718
+ echo "====================================================="
719
+
720
+ info "restarting daemon A on MySQL (ework-aio restart may not reload .env)"
721
+ info "daemon .env WORK_DB_* lines:"
722
+ grep WORK_DB "$DATA_DIR/ework-daemon/.env" 2>/dev/null || echo " (none found)"
723
+ ework-aio stop daemon --data-dir "$DATA_DIR" 2>&1 | tail -1
724
+ sleep 1
725
+ ework-aio start daemon --data-dir "$DATA_DIR" 2>&1 | tail -2
726
+ sleep 3
727
+ timeout 3 curl -sf "http://127.0.0.1:$DAEMON_PORT/healthz" >/dev/null 2>&1 \
728
+ || fail "daemon A did not respond after restart"
729
+ DAEMON_A_DB=$(grep '"msg":" db:' "$DATA_DIR/run/daemon.log" 2>/dev/null | tail -1 | sed 's/.*"msg":"//' | sed 's/".*//' || echo "?")
730
+ info "daemon A startup log: $DAEMON_A_DB"
731
+ [[ "$DAEMON_A_DB" == *"3312"* || "$DAEMON_A_DB" == *"mysql"* ]] \
732
+ || fail "daemon A is NOT on MySQL (log: $DAEMON_A_DB) — coordination requires shared DB"
733
+ pass "daemon A on MySQL"
734
+
735
+ DAEMON2_PORT=$((DAEMON_PORT + 1))
736
+ DAEMON2_DIR="$DATA_DIR/ework-daemon-2"
737
+ DAEMON2_BIN="$(npm root -g)/ework-aio/node_modules/ework-daemon/bin/ework-daemon-server.js"
738
+
739
+ mkdir -p "$DAEMON2_DIR"
740
+ cp "$DATA_DIR/ework-daemon/.env" "$DAEMON2_DIR/.env"
741
+ sed -i "s/^DAEMON_PORT=.*/DAEMON_PORT=$DAEMON2_PORT/" "$DAEMON2_DIR/.env"
742
+ echo "WORK_DAEMON_LEASE_TTL_MS=15000" >> "$DAEMON2_DIR/.env"
743
+
744
+ info "starting daemon B on port $DAEMON2_PORT (short TTL=15s for failover test)"
745
+ ( set -a; . "$DAEMON2_DIR/.env" 2>/dev/null; set +a
746
+ cd "$DAEMON2_DIR"
747
+ DAEMON_PORT=$DAEMON2_PORT WORK_DAEMON_LEASE_TTL_MS=15000 \
748
+ exec bun "$DAEMON2_BIN" >> "$DATA_DIR/run/daemon-2.log" 2>&1 ) &
749
+ DAEMON2_PID=$!
750
+
751
+ for i in $(seq 1 15); do
752
+ curl -sf "http://127.0.0.1:$DAEMON2_PORT/healthz" >/dev/null 2>&1 && break
753
+ sleep 1
754
+ done
755
+ curl -sf "http://127.0.0.1:$DAEMON2_PORT/healthz" >/dev/null 2>&1 \
756
+ || { echo "=== daemon-2.log ==="; cat "$DATA_DIR/run/daemon-2.log" 2>/dev/null | tail -20; echo "=== bin check ==="; ls -la "$DAEMON2_BIN" 2>&1; fail "daemon B did not start on port $DAEMON2_PORT"; }
757
+ pass "daemon B started (pid $DAEMON2_PID, port $DAEMON2_PORT)"
758
+
759
+ info "daemon B startup config:"
760
+ head -8 "$DATA_DIR/run/daemon-2.log" 2>/dev/null | while read -r line; do echo " $line"; done
761
+
762
+ WEBHOOK_SECRET=$(grep GITEA_WEBHOOK_SECRET "$DATA_DIR/ework-daemon/.env" 2>/dev/null | cut -d= -f2)
763
+
764
+ make_hook() {
765
+ local payload="$1"
766
+ if [[ -n "$WEBHOOK_SECRET" ]]; then
767
+ local sig=$(printf '%s' "$payload" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | awk '{print $NF}')
768
+ echo "-H x-gitea-signature:$sig"
769
+ fi
770
+ }
771
+
772
+ info "test 1: no-double-spawn — send identical webhook to both daemons"
773
+ ISSUE_HOOK='{"action":"opened","issue":{"number":888,"title":"multi-daemon test","body":"coordination","state":"open","user":{"login":"e2e"}},"repository":{"owner":{"login":"e2e"},"name":"test"}}'
774
+ HOOK_HDR=$(make_hook "$ISSUE_HOOK")
775
+
776
+ timeout 5 curl -sS -o /dev/null -X POST "http://127.0.0.1:$DAEMON_PORT/webhook/gitea" \
777
+ -H "Content-Type: application/json" $HOOK_HDR -d "$ISSUE_HOOK" 2>/dev/null || true
778
+ timeout 5 curl -sS -o /dev/null -X POST "http://127.0.0.1:$DAEMON2_PORT/webhook/gitea" \
779
+ -H "Content-Type: application/json" $HOOK_HDR -d "$ISSUE_HOOK" 2>/dev/null || true
780
+ sleep 3
781
+
782
+ A_SPAWNED=$(grep -c 'session.*created.*888\|observer started.*888' "$DATA_DIR/run/daemon.log" 2>/dev/null); A_SPAWNED=${A_SPAWNED:-0}
783
+ B_SPAWNED=$(grep -c 'session.*created.*888\|observer started.*888' "$DATA_DIR/run/daemon-2.log" 2>/dev/null); B_SPAWNED=${B_SPAWNED:-0}
784
+ TOTAL=$((A_SPAWNED + B_SPAWNED))
785
+ info "daemon A spawned=$A_SPAWNED daemon B spawned=$B_SPAWNED total=$TOTAL"
786
+ [[ "$TOTAL" -eq 2 ]] \
787
+ || { echo "--- daemon A 888 lines ---"; grep '888' "$DATA_DIR/run/daemon.log" 2>/dev/null | tail -5; echo "--- daemon B 888 lines ---"; grep '888' "$DATA_DIR/run/daemon-2.log" 2>/dev/null | tail -5; fail "no-double-spawn failed: expected 2 log lines (1 spawn), got $TOTAL"; }
788
+ pass "no-double-spawn: exactly 1 daemon spawned for issue 888"
789
+
790
+ info "test 2: failover — kill owner, verify survivor takes over"
791
+ if [[ "$A_SPAWNED" -eq 1 ]]; then
792
+ OWNER_PID=$(cat "$DATA_DIR/run/daemon.pid" 2>/dev/null)
793
+ SURVIVOR_PORT=$DAEMON2_PORT
794
+ SURVIVOR_LOG="$DATA_DIR/run/daemon-2.log"
795
+ else
796
+ OWNER_PID=$DAEMON2_PID
797
+ SURVIVOR_PORT=$DAEMON_PORT
798
+ SURVIVOR_LOG="$DATA_DIR/run/daemon.log"
799
+ fi
800
+
801
+ info "killing owner (pid $OWNER_PID)"
802
+ kill "$OWNER_PID" 2>/dev/null
803
+ sleep 1
804
+ pass "owner killed"
805
+
806
+ info "waiting 18s for lease expiry (TTL=15s)..."
807
+ sleep 18
808
+
809
+ COMMENT_HOOK='{"action":"created","issue":{"number":888,"title":"multi-daemon test","body":"coordination","state":"open","user":{"login":"e2e"}},"comment":{"id":99999,"body":"failover test","user":{"login":"e2e"}},"repository":{"owner":{"login":"e2e"},"name":"test"}}'
810
+ COMMENT_HDR=$(make_hook "$COMMENT_HOOK")
811
+
812
+ info "sending comment webhook to survivor (port $SURVIVOR_PORT)"
813
+ timeout 5 curl -sS -o /dev/null -X POST "http://127.0.0.1:$SURVIVOR_PORT/webhook/gitea" \
814
+ -H "Content-Type: application/json" $COMMENT_HDR -d "$COMMENT_HOOK" 2>/dev/null || true
815
+ sleep 3
816
+
817
+ SURVIVOR_CLAIMED=$(grep -c "observer started.*888\|session.*created.*888\|claimed.*888\|absorb" "$SURVIVOR_LOG" 2>/dev/null | tail -1 || echo 0)
818
+ SURVIVOR_CLAIMED_AFTER=$(grep "observer started.*888\|session.*created.*888" "$SURVIVOR_LOG" 2>/dev/null | wc -l || echo 0)
819
+ info "survivor log shows $SURVIVOR_CLAIMED_AFTER session creation(s) for 888"
820
+ [[ "$SURVIVOR_CLAIMED_AFTER" -ge 1 ]] \
821
+ || fail "failover failed: survivor did not claim issue 888"
822
+ pass "failover: survivor claimed issue 888"
823
+
824
+ info "cleanup: stop daemon B + restart main daemon"
825
+ kill "$DAEMON2_PID" 2>/dev/null
826
+ ework-aio start daemon --data-dir "$DATA_DIR" 2>&1 | tail -3
827
+ sleep 2
828
+
829
+ else
830
+ info "skipping Phase 7 (multi-daemon requires MySQL)"
831
+ fi
832
+
712
833
  # Phase 6: uninstall
713
834
  # -----------------------------------------------------------------------------
714
835
  echo
package/src/cli.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  } from "./commands/lifecycle.ts";
32
32
  import { runLogs } from "./commands/logs.ts";
33
33
  import { runEnv } from "./commands/env.ts";
34
+ import { runAddDaemon } from "./commands/add-daemon.ts";
34
35
  import {
35
36
  runConfig,
36
37
  printConfigHelp,
@@ -67,6 +68,7 @@ Commands:
67
68
  start [web|daemon|both] Start services in PID-file mode (default both)
68
69
  stop [web|daemon|both] Stop services (SIGTERM, 5s grace, then SIGKILL)
69
70
  restart [web|daemon|both] Stop + start
71
+ add-daemon [port] Start an additional daemon instance
70
72
  ps Show PID-file mode status (alias for 'status')
71
73
 
72
74
  migrate [options] Migrate issues from a Gitea instance
@@ -480,6 +482,16 @@ export async function main(
480
482
  await runRestart(opts, logger, target);
481
483
  return 0;
482
484
  }
485
+ case "add-daemon": {
486
+ const port = positionals[1] ? Number.parseInt(positionals[1], 10) : undefined;
487
+ if (port !== undefined && (!Number.isFinite(port) || port <= 0 || port > 65535)) {
488
+ throw new InstallError(
489
+ `add-daemon: invalid port '${positionals[1]}' (must be 1-65535)`,
490
+ );
491
+ }
492
+ await runAddDaemon(opts, logger, port);
493
+ return 0;
494
+ }
483
495
  case "migrate": {
484
496
  return runDelegateScript("migrate-from-gitea.ts", argv.slice(1));
485
497
  }
@@ -0,0 +1,86 @@
1
+ import path from "node:path";
2
+ import { mkdirSync } from "node:fs";
3
+ import { Logger, InstallError } from "../log.ts";
4
+ import { resolvePaths, daemonInstancePaths } from "../paths.ts";
5
+ import { parseEnvFile, serializeEnvFile, writeEnvFileAtomic } from "../env.ts";
6
+ import { startFromSp, type ServicePaths } from "./lifecycle.ts";
7
+ import type { GlobalOptions } from "../types.ts";
8
+
9
+ export async function runAddDaemon(
10
+ opts: GlobalOptions,
11
+ logger: Logger,
12
+ port?: number,
13
+ ): Promise<void> {
14
+ const paths = resolvePaths({
15
+ dataDir: opts.dataDir,
16
+ scope: opts.scope,
17
+ useSystemd: false,
18
+ });
19
+ const instances = daemonInstancePaths(paths.dataDir, paths.runDir);
20
+ const primary = instances.find((i) => i.num === 1);
21
+ if (!primary) {
22
+ throw new InstallError(
23
+ `No primary daemon found at ${paths.daemonDataDir}. Run 'ework-aio install' first.`,
24
+ );
25
+ }
26
+
27
+ let primaryContent: string;
28
+ try {
29
+ primaryContent = await Bun.file(primary.envFile).text();
30
+ } catch (err) {
31
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
32
+ throw new InstallError(
33
+ `Primary daemon .env not found at ${primary.envFile}. Run 'ework-aio install' first.`,
34
+ );
35
+ }
36
+ throw err;
37
+ }
38
+
39
+ const parsed = parseEnvFile(primaryContent);
40
+ const basePortStr = parsed.entries.get("DAEMON_PORT");
41
+ const basePort = basePortStr ? Number.parseInt(basePortStr, 10) : NaN;
42
+ if (!Number.isFinite(basePort)) {
43
+ throw new InstallError(
44
+ `Primary daemon .env has no valid DAEMON_PORT (got '${basePortStr ?? "<unset>"}')`,
45
+ );
46
+ }
47
+ const newPort = port ?? (basePort + instances.length);
48
+
49
+ const nextNum = instances.length + 1;
50
+ const newDataDir = nextNum === 1
51
+ ? paths.daemonDataDir
52
+ : path.join(paths.dataDir, `ework-daemon-${nextNum}`);
53
+ const newEnvFile = path.join(newDataDir, ".env");
54
+ const newPidFile = path.join(paths.runDir, `daemon-${nextNum}.pid`);
55
+ const newLogFile = path.join(paths.runDir, `daemon-${nextNum}.log`);
56
+
57
+ mkdirSync(newDataDir, { recursive: true });
58
+
59
+ const patchedRaw = parsed.rawLines.map((line) => {
60
+ const eqIdx = line.indexOf("=");
61
+ if (eqIdx === -1) return line;
62
+ if (line.slice(0, eqIdx).trim().startsWith("#")) return line;
63
+ if (line.slice(0, eqIdx).trim() === "DAEMON_PORT") {
64
+ return `DAEMON_PORT=${newPort}`;
65
+ }
66
+ return line;
67
+ });
68
+ const body = serializeEnvFile(
69
+ { entries: parsed.entries, rawLines: patchedRaw },
70
+ [],
71
+ );
72
+ await writeEnvFileAtomic(newEnvFile, body);
73
+
74
+ const sp: ServicePaths = {
75
+ bin: "ework-daemon-server",
76
+ pkg: "ework-daemon",
77
+ binRelPath: "bin/ework-daemon-server.js",
78
+ dataDir: newDataDir,
79
+ envFile: newEnvFile,
80
+ pidFile: newPidFile,
81
+ logFile: newLogFile,
82
+ portKey: "DAEMON_PORT",
83
+ };
84
+ const label = `daemon-${nextNum}`;
85
+ await startFromSp(sp, label, logger);
86
+ }
@@ -9,7 +9,12 @@
9
9
  // responding" without having to curl themselves).
10
10
 
11
11
  import { Logger, InstallError } from "../log.ts";
12
- import { resolvePaths, type PathConfig } from "../paths.ts";
12
+ import {
13
+ resolvePaths,
14
+ daemonInstancePaths,
15
+ type PathConfig,
16
+ type DaemonInstance,
17
+ } from "../paths.ts";
13
18
  import {
14
19
  startProcess,
15
20
  stopProcess,
@@ -20,7 +25,7 @@ import { parseEnvFile } from "../env.ts";
20
25
  import { resolveCommand, resolveBundledBin } from "../preflight.ts";
21
26
  import type { GlobalOptions, ServiceTarget } from "../types.ts";
22
27
 
23
- interface ServicePaths {
28
+ export interface ServicePaths {
24
29
  bin: string;
25
30
  pkg: string;
26
31
  binRelPath: string;
@@ -68,19 +73,48 @@ async function loadEnv(envFile: string): Promise<NodeJS.ProcessEnv> {
68
73
  return env;
69
74
  }
70
75
 
76
+ async function readPort(sp: ServicePaths): Promise<number | null> {
77
+ try {
78
+ const content = await Bun.file(sp.envFile).text();
79
+ const portStr = parseEnvFile(content).entries.get(sp.portKey ?? "");
80
+ if (portStr) return Number.parseInt(portStr, 10) || null;
81
+ } catch {
82
+ // missing .env → no port
83
+ }
84
+ return null;
85
+ }
86
+
87
+ async function readLogTail(logFile: string, maxLines: number): Promise<string | null> {
88
+ try {
89
+ const text = await Bun.file(logFile).text();
90
+ const lines = text.trimEnd().split("\n");
91
+ return lines.slice(-maxLines).join("\n");
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
71
97
  async function startOne(
72
98
  svc: "web" | "daemon",
73
99
  paths: PathConfig,
74
100
  logger: Logger,
75
101
  ): Promise<boolean> {
76
102
  const sp = servicePaths(paths, svc);
103
+ return startFromSp(sp, svc, logger);
104
+ }
105
+
106
+ export async function startFromSp(
107
+ sp: ServicePaths,
108
+ label: string,
109
+ logger: Logger,
110
+ ): Promise<boolean> {
77
111
  const binPath = resolveBundledBin(sp.pkg, sp.binRelPath) ?? resolveCommand(sp.bin);
78
112
  if (!binPath) {
79
113
  throw new InstallError(`${sp.bin} not found — update with: npm install -g ework-aio@latest`);
80
114
  }
81
115
  const existingPid = await readPidFile(sp.pidFile);
82
116
  if (existingPid !== null && isProcessRunning(existingPid)) {
83
- logger.log(`ework-${svc} already running (pid ${existingPid})`);
117
+ logger.log(`ework-${label} already running (pid ${existingPid})`);
84
118
  return false;
85
119
  }
86
120
  const env = await loadEnv(sp.envFile);
@@ -92,41 +126,106 @@ async function startOne(
92
126
  logFile: sp.logFile,
93
127
  pidFile: sp.pidFile,
94
128
  });
95
- logger.ok(`ework-${svc} started (pid ${pid}, log ${sp.logFile})`);
129
+
130
+ await new Promise((r) => setTimeout(r, 1500));
131
+ if (!isProcessRunning(pid)) {
132
+ const tail = await readLogTail(sp.logFile, 10);
133
+ const detail = tail ? `\n${tail}` : "";
134
+ throw new InstallError(`ework-${label} failed to start (pid ${pid} exited within 1.5s)${detail}`);
135
+ }
136
+
137
+ const port = await readPort(sp);
138
+ const portStr = port !== null ? `, http://127.0.0.1:${port}` : "";
139
+ logger.ok(`ework-${label} started (pid ${pid}${portStr}, log ${sp.logFile})`);
96
140
  return true;
97
141
  }
98
142
 
99
143
  async function stopOne(svc: "web" | "daemon", paths: PathConfig, logger: Logger): Promise<boolean> {
100
144
  const sp = servicePaths(paths, svc);
145
+ return stopFromSp(sp, svc, logger);
146
+ }
147
+
148
+ export async function stopFromSp(
149
+ sp: ServicePaths,
150
+ label: string,
151
+ logger: Logger,
152
+ ): Promise<boolean> {
101
153
  try {
102
154
  const result = await stopProcess(sp.pidFile, { graceMs: 5000, sigkillAfter: true });
103
155
  if (result.killed) {
104
- logger.ok(`ework-${svc} stopped (pid ${result.pid}${result.timedOut ? ", SIGKILL after timeout" : ""})`);
156
+ logger.ok(`ework-${label} stopped (pid ${result.pid}${result.timedOut ? ", SIGKILL after timeout" : ""})`);
105
157
  } else {
106
- logger.log(`ework-${svc} was not running (stale pidfile cleaned)`);
158
+ logger.log(`ework-${label} was not running (stale pidfile cleaned)`);
107
159
  }
108
160
  return result.killed;
109
161
  } catch (err) {
110
162
  if (err instanceof Error && /not found or empty/.test(err.message)) {
111
- logger.log(`ework-${svc} not running (no pidfile at ${sp.pidFile})`);
163
+ logger.log(`ework-${label} not running (no pidfile at ${sp.pidFile})`);
112
164
  return false;
113
165
  }
114
166
  throw err;
115
167
  }
116
168
  }
117
169
 
170
+ function daemonSpFromInstance(inst: DaemonInstance): ServicePaths {
171
+ return {
172
+ bin: "ework-daemon-server",
173
+ pkg: "ework-daemon",
174
+ binRelPath: "bin/ework-daemon-server.js",
175
+ dataDir: inst.dataDir,
176
+ envFile: inst.envFile,
177
+ pidFile: inst.pidFile,
178
+ logFile: inst.logFile,
179
+ portKey: "DAEMON_PORT",
180
+ };
181
+ }
182
+
183
+ function instanceLabel(num: number): string {
184
+ return num === 1 ? "daemon" : `daemon-${num}`;
185
+ }
186
+
187
+ function iterDaemonInstances(
188
+ paths: PathConfig,
189
+ ): Array<{ sp: ServicePaths; label: string }> {
190
+ const instances = daemonInstancePaths(paths.dataDir, paths.runDir);
191
+ return instances.map((inst) => ({
192
+ sp: daemonSpFromInstance(inst),
193
+ label: instanceLabel(inst.num),
194
+ }));
195
+ }
196
+
118
197
  function targets(target: ServiceTarget): Array<"web" | "daemon"> {
119
198
  return target === "both" ? ["web", "daemon"] : [target];
120
199
  }
121
200
 
201
+ function iterTargets(
202
+ paths: PathConfig,
203
+ target: ServiceTarget,
204
+ ): Array<{ sp: ServicePaths; label: string }> {
205
+ const out: Array<{ sp: ServicePaths; label: string }> = [];
206
+ for (const svc of targets(target)) {
207
+ if (svc === "web") {
208
+ out.push({ sp: servicePaths(paths, "web"), label: "web" });
209
+ } else {
210
+ const insts = iterDaemonInstances(paths);
211
+ if (insts.length === 0) {
212
+ out.push({ sp: servicePaths(paths, "daemon"), label: "daemon" });
213
+ } else {
214
+ out.push(...insts);
215
+ }
216
+ }
217
+ }
218
+ return out;
219
+ }
220
+
122
221
  export async function runStart(
123
222
  opts: GlobalOptions,
124
223
  logger: Logger,
125
224
  target: ServiceTarget,
126
225
  ): Promise<void> {
127
226
  const paths = resolvePaths({ dataDir: opts.dataDir, scope: opts.scope, useSystemd: false });
128
- for (const svc of targets(target)) {
129
- await startOne(svc, paths, logger);
227
+ for (const { sp, label } of iterTargets(paths, target)) {
228
+ await startFromSp(sp, label, logger);
130
229
  }
131
230
  }
132
231
 
@@ -136,8 +235,8 @@ export async function runStop(
136
235
  target: ServiceTarget,
137
236
  ): Promise<void> {
138
237
  const paths = resolvePaths({ dataDir: opts.dataDir, scope: opts.scope, useSystemd: false });
139
- for (const svc of targets(target)) {
140
- await stopOne(svc, paths, logger);
238
+ for (const { sp, label } of iterTargets(paths, target)) {
239
+ await stopFromSp(sp, label, logger);
141
240
  }
142
241
  }
143
242
 
@@ -147,14 +246,14 @@ export async function runRestart(
147
246
  target: ServiceTarget,
148
247
  ): Promise<void> {
149
248
  const paths = resolvePaths({ dataDir: opts.dataDir, scope: opts.scope, useSystemd: false });
150
- for (const svc of targets(target)) {
151
- await stopOne(svc, paths, logger);
152
- await startOne(svc, paths, logger);
249
+ for (const { sp, label } of iterTargets(paths, target)) {
250
+ await stopFromSp(sp, label, logger);
251
+ await startFromSp(sp, label, logger);
153
252
  }
154
253
  }
155
254
 
156
255
  export interface StatusEntry {
157
- svc: "web" | "daemon";
256
+ svc: string;
158
257
  pid: number | null;
159
258
  alive: boolean;
160
259
  port: number | null;
@@ -168,27 +267,25 @@ export async function runStatus(opts: GlobalOptions, logger: Logger): Promise<St
168
267
  logger.log("ework-aio status (PID-file mode)");
169
268
  logger.hr();
170
269
 
270
+ const probeTargets: Array<{ sp: ServicePaths; label: string }> = [
271
+ { sp: servicePaths(paths, "web"), label: "web" },
272
+ ];
273
+ const daemonInsts = iterDaemonInstances(paths);
274
+ if (daemonInsts.length === 0) {
275
+ probeTargets.push({ sp: servicePaths(paths, "daemon"), label: "daemon" });
276
+ } else {
277
+ probeTargets.push(...daemonInsts);
278
+ }
279
+
171
280
  const entries: StatusEntry[] = [];
172
- for (const svc of ["web", "daemon"] as const) {
173
- const sp = servicePaths(paths, svc);
281
+ for (const { sp, label } of probeTargets) {
174
282
  const pid = await readPidFile(sp.pidFile);
175
283
  const alive = pid !== null && isProcessRunning(pid);
176
284
 
177
- let port: number | null = null;
178
- try {
179
- const content = await Bun.file(sp.envFile).text();
180
- const portStr = parseEnvFile(content).entries.get(sp.portKey ?? "");
181
- if (portStr) port = Number.parseInt(portStr, 10) || null;
182
- } catch {
183
- // missing .env → no port
184
- }
185
-
285
+ const port = await readPort(sp);
186
286
  let listening: boolean | null = null;
187
287
  if (port !== null) {
188
- // web exposes /login; daemon doesn't. Probe a per-service URL so the
189
- // status line doesn't lie about the daemon being "not responding"
190
- // when it's actually healthy.
191
- const probeUrl = svc === "web"
288
+ const probeUrl = label === "web"
192
289
  ? `http://127.0.0.1:${port}/login`
193
290
  : `http://127.0.0.1:${port}/`;
194
291
  try {
@@ -199,13 +296,13 @@ export async function runStatus(opts: GlobalOptions, logger: Logger): Promise<St
199
296
  }
200
297
  }
201
298
 
202
- entries.push({ svc, pid, alive, port, listening });
299
+ entries.push({ svc: label, pid, alive, port, listening });
203
300
 
204
301
  const pidStr = pid === null ? "—" : `pid ${pid}`;
205
302
  const aliveStr = alive ? "✓ running" : "✗ not running";
206
303
  const portStr = port === null ? "(no port in .env)" : `:${port}`;
207
304
  const listenStr = listening === null ? "" : listening ? " ✓ listening" : " ✗ not responding";
208
- logger.log(` ework-${svc.padEnd(8)} ${pidStr.padEnd(10)} ${aliveStr} ${portStr}${listenStr}`);
305
+ logger.log(` ework-${label.padEnd(10)} ${pidStr.padEnd(10)} ${aliveStr} ${portStr}${listenStr}`);
209
306
  }
210
307
  logger.hr();
211
308
  return entries;
package/src/paths.ts CHANGED
@@ -18,6 +18,7 @@
18
18
 
19
19
  import path from "node:path";
20
20
  import os from "node:os";
21
+ import { existsSync } from "node:fs";
21
22
 
22
23
  export interface PathConfig {
23
24
  dataDir: string;
@@ -93,3 +94,37 @@ export function resolvePaths(opts: ResolvePathsOptions): PathConfig {
93
94
  daemonUnitFile: unitDir ? path.join(unitDir, "ework-daemon.service") : null,
94
95
  };
95
96
  }
97
+
98
+ export interface DaemonInstance {
99
+ num: number;
100
+ dataDir: string;
101
+ envFile: string;
102
+ pidFile: string;
103
+ logFile: string;
104
+ }
105
+
106
+ export function daemonInstancePaths(dataDir: string, runDir: string): DaemonInstance[] {
107
+ const instances: DaemonInstance[] = [];
108
+ const primaryDir = path.join(dataDir, "ework-daemon");
109
+ if (existsSync(primaryDir)) {
110
+ instances.push({
111
+ num: 1,
112
+ dataDir: primaryDir,
113
+ envFile: path.join(primaryDir, ".env"),
114
+ pidFile: path.join(runDir, "daemon.pid"),
115
+ logFile: path.join(runDir, "daemon.log"),
116
+ });
117
+ }
118
+ for (let i = 2; i <= 100; i++) {
119
+ const dir = path.join(dataDir, `ework-daemon-${i}`);
120
+ if (!existsSync(dir)) break;
121
+ instances.push({
122
+ num: i,
123
+ dataDir: dir,
124
+ envFile: path.join(dir, ".env"),
125
+ pidFile: path.join(runDir, `daemon-${i}.pid`),
126
+ logFile: path.join(runDir, `daemon-${i}.log`),
127
+ });
128
+ }
129
+ return instances;
130
+ }