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 +2 -2
- package/scripts/e2e-install.sh +121 -0
- package/src/cli.ts +12 -0
- package/src/commands/add-daemon.ts +86 -0
- package/src/commands/lifecycle.ts +129 -32
- package/src/paths.ts +35 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ework-aio",
|
|
3
|
-
"version": "0.5.
|
|
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.
|
|
50
|
+
"ework-web": "^0.8.0",
|
|
51
51
|
"opencode-ework": "^0.1.0",
|
|
52
52
|
"zod": "^3.23.0"
|
|
53
53
|
},
|
package/scripts/e2e-install.sh
CHANGED
|
@@ -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 {
|
|
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-${
|
|
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
|
-
|
|
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-${
|
|
156
|
+
logger.ok(`ework-${label} stopped (pid ${result.pid}${result.timedOut ? ", SIGKILL after timeout" : ""})`);
|
|
105
157
|
} else {
|
|
106
|
-
logger.log(`ework-${
|
|
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-${
|
|
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
|
|
129
|
-
await
|
|
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
|
|
140
|
-
await
|
|
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
|
|
151
|
-
await
|
|
152
|
-
await
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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-${
|
|
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
|
+
}
|