ework-aio 0.5.2 → 0.5.4
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 +99 -22
- 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.4",
|
|
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.9.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;
|
|
@@ -95,13 +100,21 @@ async function startOne(
|
|
|
95
100
|
logger: Logger,
|
|
96
101
|
): Promise<boolean> {
|
|
97
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> {
|
|
98
111
|
const binPath = resolveBundledBin(sp.pkg, sp.binRelPath) ?? resolveCommand(sp.bin);
|
|
99
112
|
if (!binPath) {
|
|
100
113
|
throw new InstallError(`${sp.bin} not found — update with: npm install -g ework-aio@latest`);
|
|
101
114
|
}
|
|
102
115
|
const existingPid = await readPidFile(sp.pidFile);
|
|
103
116
|
if (existingPid !== null && isProcessRunning(existingPid)) {
|
|
104
|
-
logger.log(`ework-${
|
|
117
|
+
logger.log(`ework-${label} already running (pid ${existingPid})`);
|
|
105
118
|
return false;
|
|
106
119
|
}
|
|
107
120
|
const env = await loadEnv(sp.envFile);
|
|
@@ -118,46 +131,101 @@ async function startOne(
|
|
|
118
131
|
if (!isProcessRunning(pid)) {
|
|
119
132
|
const tail = await readLogTail(sp.logFile, 10);
|
|
120
133
|
const detail = tail ? `\n${tail}` : "";
|
|
121
|
-
throw new InstallError(`ework-${
|
|
134
|
+
throw new InstallError(`ework-${label} failed to start (pid ${pid} exited within 1.5s)${detail}`);
|
|
122
135
|
}
|
|
123
136
|
|
|
124
137
|
const port = await readPort(sp);
|
|
125
138
|
const portStr = port !== null ? `, http://127.0.0.1:${port}` : "";
|
|
126
|
-
logger.ok(`ework-${
|
|
139
|
+
logger.ok(`ework-${label} started (pid ${pid}${portStr}, log ${sp.logFile})`);
|
|
127
140
|
return true;
|
|
128
141
|
}
|
|
129
142
|
|
|
130
143
|
async function stopOne(svc: "web" | "daemon", paths: PathConfig, logger: Logger): Promise<boolean> {
|
|
131
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> {
|
|
132
153
|
try {
|
|
133
154
|
const result = await stopProcess(sp.pidFile, { graceMs: 5000, sigkillAfter: true });
|
|
134
155
|
if (result.killed) {
|
|
135
|
-
logger.ok(`ework-${
|
|
156
|
+
logger.ok(`ework-${label} stopped (pid ${result.pid}${result.timedOut ? ", SIGKILL after timeout" : ""})`);
|
|
136
157
|
} else {
|
|
137
|
-
logger.log(`ework-${
|
|
158
|
+
logger.log(`ework-${label} was not running (stale pidfile cleaned)`);
|
|
138
159
|
}
|
|
139
160
|
return result.killed;
|
|
140
161
|
} catch (err) {
|
|
141
162
|
if (err instanceof Error && /not found or empty/.test(err.message)) {
|
|
142
|
-
logger.log(`ework-${
|
|
163
|
+
logger.log(`ework-${label} not running (no pidfile at ${sp.pidFile})`);
|
|
143
164
|
return false;
|
|
144
165
|
}
|
|
145
166
|
throw err;
|
|
146
167
|
}
|
|
147
168
|
}
|
|
148
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
|
+
|
|
149
197
|
function targets(target: ServiceTarget): Array<"web" | "daemon"> {
|
|
150
198
|
return target === "both" ? ["web", "daemon"] : [target];
|
|
151
199
|
}
|
|
152
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
|
+
|
|
153
221
|
export async function runStart(
|
|
154
222
|
opts: GlobalOptions,
|
|
155
223
|
logger: Logger,
|
|
156
224
|
target: ServiceTarget,
|
|
157
225
|
): Promise<void> {
|
|
158
226
|
const paths = resolvePaths({ dataDir: opts.dataDir, scope: opts.scope, useSystemd: false });
|
|
159
|
-
for (const
|
|
160
|
-
await
|
|
227
|
+
for (const { sp, label } of iterTargets(paths, target)) {
|
|
228
|
+
await startFromSp(sp, label, logger);
|
|
161
229
|
}
|
|
162
230
|
}
|
|
163
231
|
|
|
@@ -167,8 +235,8 @@ export async function runStop(
|
|
|
167
235
|
target: ServiceTarget,
|
|
168
236
|
): Promise<void> {
|
|
169
237
|
const paths = resolvePaths({ dataDir: opts.dataDir, scope: opts.scope, useSystemd: false });
|
|
170
|
-
for (const
|
|
171
|
-
await
|
|
238
|
+
for (const { sp, label } of iterTargets(paths, target)) {
|
|
239
|
+
await stopFromSp(sp, label, logger);
|
|
172
240
|
}
|
|
173
241
|
}
|
|
174
242
|
|
|
@@ -178,14 +246,14 @@ export async function runRestart(
|
|
|
178
246
|
target: ServiceTarget,
|
|
179
247
|
): Promise<void> {
|
|
180
248
|
const paths = resolvePaths({ dataDir: opts.dataDir, scope: opts.scope, useSystemd: false });
|
|
181
|
-
for (const
|
|
182
|
-
await
|
|
183
|
-
await
|
|
249
|
+
for (const { sp, label } of iterTargets(paths, target)) {
|
|
250
|
+
await stopFromSp(sp, label, logger);
|
|
251
|
+
await startFromSp(sp, label, logger);
|
|
184
252
|
}
|
|
185
253
|
}
|
|
186
254
|
|
|
187
255
|
export interface StatusEntry {
|
|
188
|
-
svc:
|
|
256
|
+
svc: string;
|
|
189
257
|
pid: number | null;
|
|
190
258
|
alive: boolean;
|
|
191
259
|
port: number | null;
|
|
@@ -199,16 +267,25 @@ export async function runStatus(opts: GlobalOptions, logger: Logger): Promise<St
|
|
|
199
267
|
logger.log("ework-aio status (PID-file mode)");
|
|
200
268
|
logger.hr();
|
|
201
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
|
+
|
|
202
280
|
const entries: StatusEntry[] = [];
|
|
203
|
-
for (const
|
|
204
|
-
const sp = servicePaths(paths, svc);
|
|
281
|
+
for (const { sp, label } of probeTargets) {
|
|
205
282
|
const pid = await readPidFile(sp.pidFile);
|
|
206
283
|
const alive = pid !== null && isProcessRunning(pid);
|
|
207
284
|
|
|
208
|
-
|
|
285
|
+
const port = await readPort(sp);
|
|
209
286
|
let listening: boolean | null = null;
|
|
210
287
|
if (port !== null) {
|
|
211
|
-
const probeUrl =
|
|
288
|
+
const probeUrl = label === "web"
|
|
212
289
|
? `http://127.0.0.1:${port}/login`
|
|
213
290
|
: `http://127.0.0.1:${port}/`;
|
|
214
291
|
try {
|
|
@@ -219,13 +296,13 @@ export async function runStatus(opts: GlobalOptions, logger: Logger): Promise<St
|
|
|
219
296
|
}
|
|
220
297
|
}
|
|
221
298
|
|
|
222
|
-
entries.push({ svc, pid, alive, port, listening });
|
|
299
|
+
entries.push({ svc: label, pid, alive, port, listening });
|
|
223
300
|
|
|
224
301
|
const pidStr = pid === null ? "—" : `pid ${pid}`;
|
|
225
302
|
const aliveStr = alive ? "✓ running" : "✗ not running";
|
|
226
303
|
const portStr = port === null ? "(no port in .env)" : `:${port}`;
|
|
227
304
|
const listenStr = listening === null ? "" : listening ? " ✓ listening" : " ✗ not responding";
|
|
228
|
-
logger.log(` ework-${
|
|
305
|
+
logger.log(` ework-${label.padEnd(10)} ${pidStr.padEnd(10)} ${aliveStr} ${portStr}${listenStr}`);
|
|
229
306
|
}
|
|
230
307
|
logger.hr();
|
|
231
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
|
+
}
|