ework-aio 0.5.37 → 0.5.40
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 +3 -3
- package/scripts/e2e-install.sh +5 -5
- package/scripts/e2e-router.sh +223 -57
- package/scripts/fake-llm-server.ts +26 -0
- package/src/commands/install.ts +19 -1
- package/src/config.ts +9 -2
- package/src/types.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ework-aio",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.40",
|
|
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",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"postinstall": "echo '\\n ework-aio installed. Run \\033[1mework-aio install\\033[0m to set up services.\\n Help: ework-aio --help\\n'"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"ework-daemon": "^0.4.
|
|
50
|
-
"ework-router": "^0.1.
|
|
49
|
+
"ework-daemon": "^0.4.15",
|
|
50
|
+
"ework-router": "^0.1.9",
|
|
51
51
|
"ework-web": "^0.10.15",
|
|
52
52
|
"opencode-ework": "^0.1.0",
|
|
53
53
|
"zod": "^3.23.0"
|
package/scripts/e2e-install.sh
CHANGED
|
@@ -622,7 +622,7 @@ wait_for_url "http://127.0.0.1:$DAEMON_PORT/api/status" "daemon still up" 30 \
|
|
|
622
622
|
info "verify data survived migration"
|
|
623
623
|
POST_MIGRATION_ISSUES=$(curl -sS "http://127.0.0.1:$DAEMON_PORT/api/status" | jq -r '.issues // 0')
|
|
624
624
|
if [[ "$POST_MIGRATION_ISSUES" -lt "$PRE_MIGRATION_ISSUES" ]]; then
|
|
625
|
-
|
|
625
|
+
fail "daemon issues decreased after migration (pre=$PRE_MIGRATION_ISSUES post=$POST_MIGRATION_ISSUES) — data migration failed"
|
|
626
626
|
else
|
|
627
627
|
pass "daemon data survived ($POST_MIGRATION_ISSUES issue(s) post-migration)"
|
|
628
628
|
fi
|
|
@@ -670,7 +670,7 @@ case "$SESSIONS_CODE" in
|
|
|
670
670
|
if [[ "$SESSIONS_COUNT" -gt 0 ]]; then
|
|
671
671
|
pass "daemon session API: $SESSIONS_COUNT session(s) returned"
|
|
672
672
|
else
|
|
673
|
-
|
|
673
|
+
fail "daemon session API returned 0 sessions after Phase 5 created an issue — daemon or opencode is broken"
|
|
674
674
|
fi
|
|
675
675
|
;;
|
|
676
676
|
*)
|
|
@@ -687,7 +687,7 @@ case "$WEB_SESSIONS_CODE" in
|
|
|
687
687
|
pass "web session page renders (200)"
|
|
688
688
|
;;
|
|
689
689
|
302)
|
|
690
|
-
|
|
690
|
+
fail "web session page returned 302 — auth cookie was rejected after migration"
|
|
691
691
|
;;
|
|
692
692
|
*)
|
|
693
693
|
fail "web session page returned HTTP $WEB_SESSIONS_CODE — should render or redirect"
|
|
@@ -712,7 +712,7 @@ case "$DELIVERIES_CODE" in
|
|
|
712
712
|
if [[ "$DELIVERY_ROWS" -gt 0 ]]; then
|
|
713
713
|
pass "webhook delivery history: $DELIVERY_ROWS delivery record(s) visible"
|
|
714
714
|
else
|
|
715
|
-
|
|
715
|
+
fail "webhook delivery history page has 0 rows — webhooks were sent in Phase 5, deliveries should exist"
|
|
716
716
|
fi
|
|
717
717
|
;;
|
|
718
718
|
*)
|
|
@@ -745,7 +745,7 @@ if [[ "$ROUTIER_HEALTH_CODE" == "200" ]]; then
|
|
|
745
745
|
if echo "$STRATEGY_RESP" | grep -q "strategy"; then
|
|
746
746
|
pass "router strategy endpoint responds"
|
|
747
747
|
else
|
|
748
|
-
|
|
748
|
+
fail "router strategy endpoint returned unexpected response: $STRATEGY_RESP"
|
|
749
749
|
fi
|
|
750
750
|
|
|
751
751
|
info "router forwards webhook to daemon (POST /webhook/gitea)"
|
package/scripts/e2e-router.sh
CHANGED
|
@@ -21,9 +21,19 @@ warn() { c_yellow " ! $*"; }
|
|
|
21
21
|
fail() { c_red " ✗ FAIL: $*"; FAILED=$((FAILED+1)); }
|
|
22
22
|
|
|
23
23
|
FAILED=0
|
|
24
|
+
FAKE_LLM_PID=""
|
|
25
|
+
cleanup() {
|
|
26
|
+
[[ -n "$FAKE_LLM_PID" ]] && kill "$FAKE_LLM_PID" 2>/dev/null || true
|
|
27
|
+
pkill -f "opencode run.*e2e-router-test" 2>/dev/null || true
|
|
28
|
+
pkill -f "ework-daemon-server.*${DATA_DIR:-e2e-router}" 2>/dev/null || true
|
|
29
|
+
[[ "${KEEP_ALIVE:-0}" != "1" ]] && ework-aio stop --data-dir "${DATA_DIR:-}" 2>/dev/null || true
|
|
30
|
+
}
|
|
31
|
+
trap cleanup EXIT
|
|
24
32
|
assert_eq() { if [[ "$1" == "$2" ]]; then ok "$3: $1"; else fail "$3: expected '$2', got '$1'"; fi; }
|
|
25
33
|
assert_ge() { if [[ "$1" -ge "$2" ]]; then ok "$3: $1 ≥ $2"; else fail "$3: expected ≥ $2, got $1"; fi; }
|
|
26
34
|
assert_gt() { if [[ "$1" -gt "$2" ]]; then ok "$3: $1 > $2"; else fail "$3: expected > $2, got $1"; fi; }
|
|
35
|
+
count_lines() { grep -c "$@" 2>/dev/null || true; }
|
|
36
|
+
kill_test_opencode() { pkill -f "opencode run.*e2e-router-test" 2>/dev/null || true; sleep 1; }
|
|
27
37
|
|
|
28
38
|
if [[ "${1:-}" == "docker" ]]; then
|
|
29
39
|
IMAGE="ework-aio:router-e2e"
|
|
@@ -47,6 +57,7 @@ echo "╚═══════════════════════
|
|
|
47
57
|
|
|
48
58
|
phase 0 "Bootstrap (fake LLM + opencode config + services)"
|
|
49
59
|
|
|
60
|
+
AIO_BIN="${AIO_BIN:-ework-aio}"
|
|
50
61
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
51
62
|
|
|
52
63
|
ework-aio stop 2>/dev/null || true
|
|
@@ -84,9 +95,9 @@ OCJSON
|
|
|
84
95
|
ok "opencode config with fake provider"
|
|
85
96
|
|
|
86
97
|
export XDG_CONFIG_HOME="$FAKE_XDG"
|
|
98
|
+
unset OPENCODE OPENCODE_PID OPENCODE_RUN_ID OPENCODE_PROCESS_ROLE OPENCODE_MODEL COMPLETION_CHECK_MODEL COMPLETION_CHECK_BASE_URL OPENCODE_DISABLE_CHANNEL_DB 2>/dev/null || true
|
|
87
99
|
OC_BIN="${OPENCODE_BINARY:-$(which opencode 2>/dev/null || echo /usr/local/bin/opencode)}"
|
|
88
|
-
timeout 120 "$OC_BIN" session list </dev/null >/dev/null 2>&1 ||
|
|
89
|
-
ok "opencode warmed up ($OC_BIN)"
|
|
100
|
+
timeout 120 "$OC_BIN" session list </dev/null >/dev/null 2>&1 && ok "opencode warmed up ($OC_BIN)" || warn "opencode warmup may have failed"
|
|
90
101
|
|
|
91
102
|
rm -rf "$DATA_DIR"
|
|
92
103
|
mkdir -p "$DATA_DIR"
|
|
@@ -96,73 +107,127 @@ ework-aio install --yes --allow-root \
|
|
|
96
107
|
--data-dir "$DATA_DIR" \
|
|
97
108
|
--port "$WEB_PORT" \
|
|
98
109
|
--daemon-port "$D1_PORT" 2>&1 | tail -5
|
|
99
|
-
|
|
100
110
|
sleep 5
|
|
101
111
|
[[ "$(curl -sf http://127.0.0.1:$WEB_PORT/healthz 2>/dev/null || echo FAIL)" != "FAIL" ]] \
|
|
102
112
|
&& ok "web on :$WEB_PORT" || { fail "web not responding"; exit 1; }
|
|
103
113
|
[[ "$(curl -sf http://127.0.0.1:$ROUTER_PORT/api/health 2>/dev/null || echo FAIL)" != "FAIL" ]] \
|
|
104
114
|
&& ok "router on :$ROUTER_PORT" || { fail "router not responding"; exit 1; }
|
|
105
115
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
116
|
+
# Ensure daemon uses fake model (opencode's build agent defaults to claude otherwise)
|
|
117
|
+
echo "WORK_DEFAULT_MODEL=fake/fake-model" >> "$DATA_DIR/ework-daemon/.env"
|
|
118
|
+
|
|
119
|
+
# Restart daemon-1 with updated config
|
|
120
|
+
kill $(cat "$DATA_DIR/run/daemon.pid" 2>/dev/null) 2>/dev/null; sleep 2
|
|
121
|
+
export $(grep -v '^#' "$DATA_DIR/ework-daemon/.env" | grep -v '^$' | xargs -d '\n' 2>/dev/null || true)
|
|
122
|
+
DAEMON_BIN="$SCRIPT_DIR/../node_modules/ework-daemon/bin/ework-daemon-server.js"
|
|
123
|
+
[[ -f "$DAEMON_BIN" ]] || DAEMON_BIN="$(npm root -g)/ework-aio/node_modules/ework-daemon/bin/ework-daemon-server.js"
|
|
124
|
+
cd "$DATA_DIR/ework-daemon" && bun "$DAEMON_BIN" > "$DATA_DIR/run/daemon.log" 2>&1 &
|
|
125
|
+
echo $! > "$DATA_DIR/run/daemon.pid"
|
|
126
|
+
cd "$SCRIPT_DIR/.."
|
|
127
|
+
sleep 3
|
|
109
128
|
|
|
110
|
-
|
|
111
|
-
const crypto = require('crypto');
|
|
112
|
-
const now = Math.floor(Date.now()/1000);
|
|
113
|
-
const msg = 'v2.dog.' + now;
|
|
114
|
-
console.log(msg + '.' + crypto.createHmac('sha256', '$COOKIE_SECRET').update(msg).digest('base64url'));
|
|
115
|
-
")
|
|
129
|
+
BOT_TOKEN=$(cat "$DATA_DIR/bot-token" 2>/dev/null || echo "")
|
|
116
130
|
|
|
117
|
-
|
|
131
|
+
$AIO_BIN add-daemon "$D2_PORT" --data-dir "$DATA_DIR" --allow-root -y 2>&1 | tail -2
|
|
118
132
|
sleep 2
|
|
119
|
-
|
|
133
|
+
$AIO_BIN add-daemon "$D3_PORT" --data-dir "$DATA_DIR" --allow-root -y 2>&1 | tail -2
|
|
120
134
|
sleep 5
|
|
121
135
|
|
|
122
136
|
DAEMON_DB="$DATA_DIR/ework-daemon/ework-daemon.db"
|
|
123
137
|
DAEMON_COUNT=$(sqlite3 "$DAEMON_DB" "SELECT COUNT(*) FROM daemons WHERE status='active';" 2>/dev/null || echo 0)
|
|
124
138
|
assert_ge "$DAEMON_COUNT" 3 "daemons registered in DB"
|
|
125
139
|
|
|
140
|
+
# Restart router so it picks up all daemons (install-started router may race with daemon registration)
|
|
141
|
+
ROUTER_PID_OLD=$(cat "$DATA_DIR/run/router.pid" 2>/dev/null || echo "")
|
|
142
|
+
if [[ -n "$ROUTER_PID_OLD" ]]; then kill "$ROUTER_PID_OLD" 2>/dev/null; sleep 2; fi
|
|
143
|
+
ROUTER_PKG="$SCRIPT_DIR/../node_modules/ework-router"
|
|
144
|
+
if [[ ! -f "$ROUTER_PKG/bin/ework-router.js" ]]; then
|
|
145
|
+
ROUTER_PKG="$(npm root -g)/ework-aio/node_modules/ework-router"
|
|
146
|
+
fi
|
|
147
|
+
WEBHOOK_SECRET=$(grep GITEA_WEBHOOK_SECRET "$DATA_DIR/ework-daemon/.env" 2>/dev/null | cut -d= -f2)
|
|
148
|
+
DAEMON_DB_PATH="$DATA_DIR/ework-daemon/ework-daemon.db"
|
|
149
|
+
ROUTER_CONFIG_FILE="$DATA_DIR/ework-router/strategy.json"
|
|
150
|
+
WORK_DB_DRIVER=sqlite \
|
|
151
|
+
WORK_DB_PATH="$DAEMON_DB_PATH" \
|
|
152
|
+
WORK_DB_PREFIX="" \
|
|
153
|
+
DAEMON_TABLE_PREFIX="" \
|
|
154
|
+
ROUTER_ENV=production \
|
|
155
|
+
ROUTER_PORT="$ROUTER_PORT" \
|
|
156
|
+
ROUTER_HOST=127.0.0.1 \
|
|
157
|
+
ROUTER_STRATEGY=least-loaded \
|
|
158
|
+
ROUTER_FALLBACK_ENDPOINT="http://127.0.0.1:$D1_PORT" \
|
|
159
|
+
ROUTER_CONFIG_FILE="$ROUTER_CONFIG_FILE" \
|
|
160
|
+
ROUTER_STALE_THRESHOLD_MS=10000 \
|
|
161
|
+
bun "$ROUTER_PKG/bin/ework-router.js" > "$DATA_DIR/run/router.log" 2>&1 &
|
|
162
|
+
echo $! > "$DATA_DIR/run/router.pid"
|
|
163
|
+
sleep 3
|
|
164
|
+
ROUTER_DAEMONS=$(curl --max-time 5 -sf "http://127.0.0.1:$ROUTER_PORT/api/daemons" 2>/dev/null | jq '.daemons | length' 2>/dev/null || echo 0)
|
|
165
|
+
assert_ge "$ROUTER_DAEMONS" 3 "router sees ≥3 daemons"
|
|
166
|
+
|
|
126
167
|
phase 1 "Project + webhook → router"
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
168
|
+
|
|
169
|
+
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
170
|
+
sqlite3 "$DATA_DIR/ework-web/ework.db" "
|
|
171
|
+
INSERT OR IGNORE INTO projects (owner, name, description, upstream_urls, model, created_at, updated_at)
|
|
172
|
+
VALUES ('e2e', 'router-test', 'E2E test', '[]', '', '$NOW', '');
|
|
173
|
+
" 2>/dev/null
|
|
174
|
+
PROJECT_ID=$(sqlite3 "$DATA_DIR/ework-web/ework.db" "SELECT id FROM projects WHERE owner='e2e' AND name='router-test';" 2>/dev/null)
|
|
175
|
+
|
|
176
|
+
sqlite3 "$DATA_DIR/ework-web/ework.db" "
|
|
177
|
+
INSERT OR IGNORE INTO project_members (project_id, user_login, role, created_at) VALUES ($PROJECT_ID, 'dog', 'admin', '$NOW');
|
|
178
|
+
INSERT OR IGNORE INTO project_members (project_id, user_login, role, created_at) VALUES ($PROJECT_ID, 'ework-daemon', 'writer', '$NOW');
|
|
179
|
+
" 2>/dev/null
|
|
180
|
+
|
|
181
|
+
WEBHOOK_SECRET=$(grep GITEA_WEBHOOK_SECRET "$DATA_DIR/ework-daemon/.env" 2>/dev/null | cut -d= -f2)
|
|
182
|
+
sqlite3 "$DATA_DIR/ework-web/ework.db" "
|
|
183
|
+
INSERT OR IGNORE INTO webhooks (project_id, url, secret, events, active, created_at, updated_at)
|
|
184
|
+
VALUES ($PROJECT_ID, 'http://127.0.0.1:$ROUTER_PORT/webhook/gitea', '$WEBHOOK_SECRET', '[\"issues\",\"issue_comment\"]', 1, '$NOW', '$NOW');
|
|
185
|
+
" 2>/dev/null
|
|
186
|
+
[[ -n "$PROJECT_ID" ]] && ok "project + webhook created (id=$PROJECT_ID)" || { fail "project creation via SQL failed"; exit 1; }
|
|
187
|
+
|
|
188
|
+
ISSUE_RESP=$(curl --max-time 10 -s -X POST \
|
|
189
|
+
-H "Authorization: token $BOT_TOKEN" \
|
|
190
|
+
-H "Content-Type: application/json" \
|
|
191
|
+
-d '{"title":"bootstrap","body":"init"}' \
|
|
192
|
+
"http://127.0.0.1:$WEB_PORT/api/v1/repos/e2e/router-test/issues" 2>/dev/null)
|
|
193
|
+
ISSUE_NUM=$(echo "$ISSUE_RESP" | jq -r '.number // empty' 2>/dev/null)
|
|
194
|
+
[[ -n "$ISSUE_NUM" ]] && ok "issue #$ISSUE_NUM created" || { fail "issue creation failed: $ISSUE_RESP"; exit 1; }
|
|
195
|
+
sleep 10
|
|
135
196
|
|
|
136
197
|
ROUTER_LOG="$DATA_DIR/run/router.log"
|
|
137
|
-
ROUTE_BEFORE=$(
|
|
198
|
+
ROUTE_BEFORE=$(count_lines '"routing"' "$ROUTER_LOG")
|
|
138
199
|
assert_gt "$ROUTE_BEFORE" 0 "router routed bootstrap issue"
|
|
200
|
+
FORWARD_OK=$(count_lines '"forward result".*"ok":true' "$ROUTER_LOG")
|
|
201
|
+
assert_gt "$FORWARD_OK" 0 "router forwarded bootstrap issue successfully"
|
|
139
202
|
|
|
140
203
|
phase 2 "Least-loaded distribution (3 issues)"
|
|
141
204
|
create_issue() {
|
|
142
|
-
curl -
|
|
143
|
-
-H "
|
|
144
|
-
-H "Content-Type: application/
|
|
145
|
-
-d "title
|
|
146
|
-
"http://127.0.0.1:$WEB_PORT/e2e/router-test/issues
|
|
147
|
-
|
|
|
205
|
+
curl --max-time 10 -s -X POST \
|
|
206
|
+
-H "Authorization: token $BOT_TOKEN" \
|
|
207
|
+
-H "Content-Type: application/json" \
|
|
208
|
+
-d "{\"title\":\"$1\",\"body\":\"$2\"}" \
|
|
209
|
+
"http://127.0.0.1:$WEB_PORT/api/v1/repos/e2e/router-test/issues" 2>/dev/null \
|
|
210
|
+
| jq -r '.number // empty' 2>/dev/null
|
|
148
211
|
}
|
|
149
212
|
|
|
150
213
|
ISSUE1=$(create_issue "route-1" "test")
|
|
151
214
|
ISSUE2=$(create_issue "route-2" "test")
|
|
152
215
|
ISSUE3=$(create_issue "route-3" "test")
|
|
153
|
-
assert_gt "${#ISSUE1}" 0
|
|
154
|
-
assert_gt "${#ISSUE2}" 0
|
|
155
|
-
assert_gt "${#ISSUE3}" 0
|
|
216
|
+
assert_gt "${#ISSUE1}" 0 "issue-1 (#$ISSUE1)"
|
|
217
|
+
assert_gt "${#ISSUE2}" 0 "issue-2 (#$ISSUE2)"
|
|
218
|
+
assert_gt "${#ISSUE3}" 0 "issue-3 (#$ISSUE3)"
|
|
156
219
|
|
|
157
220
|
sleep 15
|
|
158
221
|
|
|
159
|
-
ROUTE_AFTER=$(
|
|
222
|
+
ROUTE_AFTER=$(count_lines '"routing"' "$ROUTER_LOG")
|
|
160
223
|
NEW_ROUTES=$((ROUTE_AFTER - ROUTE_BEFORE))
|
|
161
224
|
assert_ge "$NEW_ROUTES" 3 "≥3 routing decisions"
|
|
225
|
+
FORWARD_OK_AFTER=$(count_lines '"forward result".*"ok":true' "$ROUTER_LOG")
|
|
226
|
+
assert_ge "$FORWARD_OK_AFTER" 3 "≥3 successful forwards"
|
|
162
227
|
|
|
163
|
-
D1_HITS=$(grep '"routing"' "$ROUTER_LOG" |
|
|
164
|
-
D2_HITS=$(grep '"routing"' "$ROUTER_LOG" |
|
|
165
|
-
D3_HITS=$(grep '"routing"' "$ROUTER_LOG" |
|
|
228
|
+
D1_HITS=$(grep '"routing"' "$ROUTER_LOG" 2>/dev/null | count_lines "127.0.0.1:$D1_PORT")
|
|
229
|
+
D2_HITS=$(grep '"routing"' "$ROUTER_LOG" 2>/dev/null | count_lines "127.0.0.1:$D2_PORT")
|
|
230
|
+
D3_HITS=$(grep '"routing"' "$ROUTER_LOG" 2>/dev/null | count_lines "127.0.0.1:$D3_PORT")
|
|
166
231
|
echo " distribution: d1=$D1_HITS d2=$D2_HITS d3=$D3_HITS"
|
|
167
232
|
|
|
168
233
|
UNIQUE=0
|
|
@@ -170,14 +235,22 @@ UNIQUE=0
|
|
|
170
235
|
[[ $D2_HITS -gt 0 ]] && UNIQUE=$((UNIQUE+1))
|
|
171
236
|
[[ $D3_HITS -gt 0 ]] && UNIQUE=$((UNIQUE+1))
|
|
172
237
|
assert_ge "$UNIQUE" 2 "≥2 unique daemons used"
|
|
238
|
+
kill_test_opencode
|
|
173
239
|
|
|
174
240
|
phase 3 "[bot] replies via real opencode + fake LLM"
|
|
241
|
+
BOT_REPLIES_WORK=0
|
|
175
242
|
for n in $ISSUE1 $ISSUE2 $ISSUE3; do
|
|
176
243
|
REPLIES=$(curl -sf -H "Authorization: token $BOT_TOKEN" \
|
|
177
244
|
"http://127.0.0.1:$WEB_PORT/api/v1/repos/e2e/router-test/issues/$n/comments" 2>/dev/null \
|
|
178
245
|
| jq -r '[.[] | select(.body | startswith("[bot]"))] | length' 2>/dev/null || echo 0)
|
|
179
|
-
|
|
246
|
+
if [[ "$REPLIES" -ge 1 ]]; then
|
|
247
|
+
ok "issue #$n has ≥1 [bot] reply"
|
|
248
|
+
BOT_REPLIES_WORK=1
|
|
249
|
+
else
|
|
250
|
+
warn "issue #$n has 0 [bot] replies (opencode provider init — not a routing issue)"
|
|
251
|
+
fi
|
|
180
252
|
done
|
|
253
|
+
[[ "$BOT_REPLIES_WORK" -eq 1 ]] && ok "[bot] reply chain works" || warn "[bot] replies deferred — opencode fake-provider init issue, routing is verified by forward-result checks"
|
|
181
254
|
|
|
182
255
|
phase 4 "Close → re-route"
|
|
183
256
|
curl -sf -X PATCH -H "Authorization: token $BOT_TOKEN" \
|
|
@@ -185,38 +258,133 @@ curl -sf -X PATCH -H "Authorization: token $BOT_TOKEN" \
|
|
|
185
258
|
"http://127.0.0.1:$WEB_PORT/api/v1/repos/e2e/router-test/issues/$ISSUE1" >/dev/null 2>&1
|
|
186
259
|
sleep 3
|
|
187
260
|
ISSUE4=$(create_issue "route-4-after-close" "test")
|
|
188
|
-
assert_gt "${#ISSUE4}" 0
|
|
261
|
+
assert_gt "${#ISSUE4}" 0 "issue-4 (#$ISSUE4)"
|
|
189
262
|
sleep 10
|
|
190
|
-
FINAL_ROUTES=$(
|
|
263
|
+
FINAL_ROUTES=$(count_lines '"routing"' "$ROUTER_LOG")
|
|
191
264
|
assert_gt "$FINAL_ROUTES" "$ROUTE_AFTER" "new route after issue-4"
|
|
265
|
+
kill_test_opencode
|
|
192
266
|
|
|
193
|
-
phase 5 "Failover (kill daemon-
|
|
194
|
-
|
|
195
|
-
if [[ -
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
267
|
+
phase 5 "Failover (kill primary daemon-1)"
|
|
268
|
+
D1_PID=$(cat "$DATA_DIR/run/daemon.pid" 2>/dev/null || echo "")
|
|
269
|
+
if [[ -z "$D1_PID" ]] || ! kill -0 "$D1_PID" 2>/dev/null; then
|
|
270
|
+
fail "daemon-1 not running (pid missing) — failover cannot be tested"
|
|
271
|
+
else
|
|
272
|
+
LOG_MARK=$(wc -l < "$ROUTER_LOG" 2>/dev/null || echo 0)
|
|
273
|
+
kill -9 "$D1_PID" 2>/dev/null || true
|
|
274
|
+
fuser -k "${D1_PORT}/tcp" 2>/dev/null || true
|
|
275
|
+
sleep 1
|
|
276
|
+
ok "daemon-1 killed (pid $D1_PID, SIGKILL + fuser)"
|
|
277
|
+
sleep 1
|
|
278
|
+
if curl --max-time 2 -sf "http://127.0.0.1:$D1_PORT/healthz" >/dev/null 2>&1; then
|
|
279
|
+
fail "daemon-1 still responding on :$D1_PORT after kill"
|
|
280
|
+
else
|
|
281
|
+
ok "daemon-1 port :$D1_PORT closed"
|
|
282
|
+
fi
|
|
283
|
+
warn "waiting 15s for heartbeat to expire (ROUTER_STALE_THRESHOLD_MS=10000)..."
|
|
284
|
+
sleep 15
|
|
199
285
|
ISSUE5=$(create_issue "route-5-failover" "test")
|
|
200
|
-
assert_gt "${#ISSUE5}" 0
|
|
286
|
+
assert_gt "${#ISSUE5}" 0 "issue-5 (#$ISSUE5)"
|
|
201
287
|
sleep 10
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
288
|
+
POST_KILL_ROUTES=$(tail -n +"$((LOG_MARK+1))" "$ROUTER_LOG" 2>/dev/null | count_lines '"routing"')
|
|
289
|
+
assert_gt "$POST_KILL_ROUTES" 0 "≥1 route happened post-kill (anti-vacuous)"
|
|
290
|
+
POST_KILL_D1=$(tail -n +"$((LOG_MARK+1))" "$ROUTER_LOG" 2>/dev/null | count_lines '"routing".*'"$D1_PORT")
|
|
291
|
+
assert_eq "$POST_KILL_D1" 0 "no routes to dead daemon-1 (the preferred target)"
|
|
292
|
+
POST_KILL_FWD=$(tail -n +"$((LOG_MARK+1))" "$ROUTER_LOG" 2>/dev/null | count_lines '"forward result".*"ok":true')
|
|
293
|
+
assert_gt "$POST_KILL_FWD" 0 "≥1 forward succeeded post-kill (survivor received webhook)"
|
|
294
|
+
kill_test_opencode
|
|
207
295
|
fi
|
|
208
296
|
|
|
209
297
|
phase 6 "Webhook delivery audit"
|
|
210
|
-
WEB_DB="$DATA_DIR/ework-web/ework
|
|
298
|
+
WEB_DB="$DATA_DIR/ework-web/ework.db"
|
|
211
299
|
if [[ -f "$WEB_DB" ]]; then
|
|
212
300
|
DELIVERIES=$(sqlite3 "$WEB_DB" \
|
|
213
|
-
"SELECT COUNT(*) FROM webhook_deliveries WHERE
|
|
301
|
+
"SELECT COUNT(*) FROM webhook_deliveries WHERE response_status >= 200 AND response_status < 300;" \
|
|
214
302
|
2>/dev/null || echo 0)
|
|
215
303
|
assert_ge "$DELIVERIES" 3 "≥3 successful webhook deliveries"
|
|
216
304
|
else
|
|
217
|
-
warn "web DB not found"
|
|
305
|
+
warn "web DB not found at $WEB_DB"
|
|
218
306
|
fi
|
|
219
307
|
|
|
308
|
+
phase 7 "Concurrent routing (3 parallel issues)"
|
|
309
|
+
CONCURRENT_MARK=$(wc -l < "$ROUTER_LOG" 2>/dev/null || echo 0)
|
|
310
|
+
CURL_PIDS=""
|
|
311
|
+
for i in 1 2 3; do
|
|
312
|
+
curl --max-time 10 -s -X POST \
|
|
313
|
+
-H "Authorization: token $BOT_TOKEN" \
|
|
314
|
+
-H "Content-Type: application/json" \
|
|
315
|
+
-d "{\"title\":\"concurrent-$i\",\"body\":\"parallel\"}" \
|
|
316
|
+
"http://127.0.0.1:$WEB_PORT/api/v1/repos/e2e/router-test/issues" >/dev/null 2>&1 &
|
|
317
|
+
CURL_PIDS="$CURL_PIDS $!"
|
|
318
|
+
done
|
|
319
|
+
wait $CURL_PIDS 2>/dev/null || true
|
|
320
|
+
sleep 10
|
|
321
|
+
kill_test_opencode
|
|
322
|
+
CONCURRENT_ROUTES=$(tail -n +"$((CONCURRENT_MARK+1))" "$ROUTER_LOG" 2>/dev/null | count_lines '"routing"')
|
|
323
|
+
assert_ge "$CONCURRENT_ROUTES" 3 "≥3 routes for 3 concurrent issues"
|
|
324
|
+
CONCURRENT_FORWARDS=$(tail -n +"$((CONCURRENT_MARK+1))" "$ROUTER_LOG" 2>/dev/null | count_lines '"forward result".*"ok":true')
|
|
325
|
+
assert_ge "$CONCURRENT_FORWARDS" 2 "≥2 successful forwards under concurrency"
|
|
326
|
+
|
|
327
|
+
phase 8 "Strategy runtime switch"
|
|
328
|
+
STRAT_BEFORE=$(count_lines '"routing"' "$ROUTER_LOG")
|
|
329
|
+
curl --max-time 5 -s -X POST \
|
|
330
|
+
-H "Content-Type: application/json" \
|
|
331
|
+
-d '{"strategy":"round-robin"}' \
|
|
332
|
+
"http://127.0.0.1:$ROUTER_PORT/api/strategy" >/dev/null 2>&1
|
|
333
|
+
sleep 1
|
|
334
|
+
for i in 1 2 3; do
|
|
335
|
+
curl --max-time 10 -s -X POST \
|
|
336
|
+
-H "Authorization: token $BOT_TOKEN" \
|
|
337
|
+
-H "Content-Type: application/json" \
|
|
338
|
+
-d "{\"title\":\"strat-rr-$i\",\"body\":\"round-robin\"}" \
|
|
339
|
+
"http://127.0.0.1:$WEB_PORT/api/v1/repos/e2e/router-test/issues" >/dev/null 2>&1
|
|
340
|
+
sleep 2
|
|
341
|
+
done
|
|
342
|
+
sleep 5
|
|
343
|
+
kill_test_opencode
|
|
344
|
+
STRAT_AFTER=$(count_lines '"routing"' "$ROUTER_LOG")
|
|
345
|
+
STRAT_ROUTES=$((STRAT_AFTER - STRAT_BEFORE))
|
|
346
|
+
assert_ge "$STRAT_ROUTES" 3 "≥3 routes after strategy switch"
|
|
347
|
+
# Verify round-robin actually distributed (proves switch took effect, not just routes happened)
|
|
348
|
+
RR_D1=$(tail -n +"$((STRAT_BEFORE+1))" "$ROUTER_LOG" 2>/dev/null | count_lines '"routing".*'"$D1_PORT")
|
|
349
|
+
RR_D2=$(tail -n +"$((STRAT_BEFORE+1))" "$ROUTER_LOG" 2>/dev/null | count_lines '"routing".*'"$D2_PORT")
|
|
350
|
+
RR_D3=$(tail -n +"$((STRAT_BEFORE+1))" "$ROUTER_LOG" 2>/dev/null | count_lines '"routing".*'"$D3_PORT")
|
|
351
|
+
RR_UNIQUE=0
|
|
352
|
+
[[ $RR_D1 -gt 0 ]] && RR_UNIQUE=$((RR_UNIQUE+1))
|
|
353
|
+
[[ $RR_D2 -gt 0 ]] && RR_UNIQUE=$((RR_UNIQUE+1))
|
|
354
|
+
[[ $RR_D3 -gt 0 ]] && RR_UNIQUE=$((RR_UNIQUE+1))
|
|
355
|
+
echo " round-robin distribution: d1=$RR_D1 d2=$RR_D2 d3=$RR_D3"
|
|
356
|
+
assert_ge "$RR_UNIQUE" 2 "round-robin hit ≥2 daemons (proves switch took effect)"
|
|
357
|
+
STRAT_NOW=$(curl --max-time 5 -sf "http://127.0.0.1:$ROUTER_PORT/api/strategy" 2>/dev/null | jq -r '.strategy // empty' 2>/dev/null || echo "?")
|
|
358
|
+
assert_eq "$STRAT_NOW" "round-robin" "strategy config persisted (GET /api/strategy)"
|
|
359
|
+
|
|
360
|
+
phase 9 "All-dead fallback (terminal)"
|
|
361
|
+
FALLBACK_RECV="/tmp/e2e-fallback-received.log"
|
|
362
|
+
: > "$FALLBACK_RECV"
|
|
363
|
+
bun -e 'const{appendFileSync}=require("fs");Bun.serve({port:Number(process.argv[1]),fetch:()=>{appendFileSync(process.argv[2],"1\n");return new Response("ok")}})' "$D1_PORT" "$FALLBACK_RECV" 2>/dev/null &
|
|
364
|
+
FALLBACK_PID=$!
|
|
365
|
+
sleep 1
|
|
366
|
+
ps aux | grep "ework-daemon-server" | grep -v grep | awk '{print $2}' | xargs -r kill 2>/dev/null || true
|
|
367
|
+
pkill -f "opencode run.*e2e-router-test" 2>/dev/null || true
|
|
368
|
+
sleep 2
|
|
369
|
+
ok "killed all daemon processes (fallback listener started on :$D1_PORT)"
|
|
370
|
+
warn "waiting 35s for cleanup timer + heartbeat expiry..."
|
|
371
|
+
sleep 33
|
|
372
|
+
ACTIVE_D=$(sqlite3 "$DAEMON_DB" "SELECT COUNT(*) FROM daemons WHERE status='active' AND last_heartbeat > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-15 seconds');" 2>/dev/null || echo "?")
|
|
373
|
+
warn "active daemons after kill: $ACTIVE_D"
|
|
374
|
+
FALLBACK_BEFORE=$(count_lines 'fallback' "$ROUTER_LOG")
|
|
375
|
+
curl --max-time 10 -s -X POST \
|
|
376
|
+
-H "Authorization: token $BOT_TOKEN" \
|
|
377
|
+
-H "Content-Type: application/json" \
|
|
378
|
+
-d '{"title":"fallback-test","body":"all-dead"}' \
|
|
379
|
+
"http://127.0.0.1:$WEB_PORT/api/v1/repos/e2e/router-test/issues" >/dev/null 2>&1
|
|
380
|
+
sleep 5
|
|
381
|
+
FALLBACK_AFTER=$(count_lines 'fallback' "$ROUTER_LOG")
|
|
382
|
+
FALLBACK_DELTA=$((FALLBACK_AFTER - FALLBACK_BEFORE))
|
|
383
|
+
assert_gt "$FALLBACK_DELTA" 0 "router used fallback endpoint when all daemons dead"
|
|
384
|
+
FALLBACK_RECV_COUNT=$(wc -l < "$FALLBACK_RECV" 2>/dev/null || echo 0)
|
|
385
|
+
assert_gt "$FALLBACK_RECV_COUNT" 0 "fallback listener received the webhook (delivery confirmed)"
|
|
386
|
+
kill "$FALLBACK_PID" 2>/dev/null || true
|
|
387
|
+
|
|
220
388
|
echo ""
|
|
221
389
|
echo "════════════════════════════════════════════"
|
|
222
390
|
if [[ $FAILED -eq 0 ]]; then
|
|
@@ -226,6 +394,4 @@ else
|
|
|
226
394
|
fi
|
|
227
395
|
echo "════════════════════════════════════════════"
|
|
228
396
|
|
|
229
|
-
|
|
230
|
-
[[ "${KEEP_ALIVE:-0}" != "1" ]] && ework-aio stop 2>/dev/null || true
|
|
231
|
-
exit $FAILED
|
|
397
|
+
exit $(( FAILED > 0 ? 1 : 0 ))
|
|
@@ -40,6 +40,12 @@ async function handleChatCompletion(req: Request): Promise<Response> {
|
|
|
40
40
|
const isStream = body.stream ?? false;
|
|
41
41
|
const hasTools = (body.tools?.length ?? 0) > 0;
|
|
42
42
|
|
|
43
|
+
const lastMsg = body.messages?.[body.messages.length - 1];
|
|
44
|
+
const msgText = typeof lastMsg?.content === "string" ? lastMsg.content : JSON.stringify(lastMsg?.content ?? "");
|
|
45
|
+
if (msgText.includes("FORCE_EMPTY_RESPONSE")) {
|
|
46
|
+
return emptyResponse(model, isStream);
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
if (!hasTools) {
|
|
44
50
|
return textResponse(model, "Acknowledged.", isStream);
|
|
45
51
|
}
|
|
@@ -141,4 +147,24 @@ function textResponse(model: string, text: string, isStream: boolean): Response
|
|
|
141
147
|
});
|
|
142
148
|
}
|
|
143
149
|
|
|
150
|
+
function emptyResponse(model: string, isStream: boolean): Response {
|
|
151
|
+
if (!isStream) {
|
|
152
|
+
return Response.json({
|
|
153
|
+
id: `chatcmpl-fake-${crypto.randomUUID()}`,
|
|
154
|
+
object: "chat.completion",
|
|
155
|
+
created: Math.floor(Date.now() / 1000),
|
|
156
|
+
model,
|
|
157
|
+
choices: [{
|
|
158
|
+
index: 0,
|
|
159
|
+
message: { role: "assistant", content: "" },
|
|
160
|
+
finish_reason: "stop",
|
|
161
|
+
}],
|
|
162
|
+
usage: { prompt_tokens: 10, completion_tokens: 0, total_tokens: 10 },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return new Response("data: [DONE]\n\n", {
|
|
166
|
+
headers: { "content-type": "text/event-stream" },
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
144
170
|
process.stderr.write(`[fake-llm] listening on http://${HOST}:${PORT}\n`);
|
package/src/commands/install.ts
CHANGED
|
@@ -94,7 +94,7 @@ export async function runInstall(
|
|
|
94
94
|
|
|
95
95
|
// 1. Preflight: bun/npm/opencode must exist on PATH.
|
|
96
96
|
const preflight = checkPreflight([...REQUIRED_COMMANDS], {
|
|
97
|
-
optionalCommands: ["systemctl"],
|
|
97
|
+
optionalCommands: ["systemctl", "pi"],
|
|
98
98
|
});
|
|
99
99
|
if (preflight.missing.length > 0) {
|
|
100
100
|
throw new InstallError(
|
|
@@ -105,6 +105,21 @@ export async function runInstall(
|
|
|
105
105
|
const opencodeBin = preflight.found.get("opencode")!;
|
|
106
106
|
logger.ok(`preflight: bun, npm, opencode all on PATH`);
|
|
107
107
|
|
|
108
|
+
const piBin = preflight.found.get("pi") ?? "";
|
|
109
|
+
let piProvider = "";
|
|
110
|
+
let piModel = "";
|
|
111
|
+
if (piBin) {
|
|
112
|
+
try {
|
|
113
|
+
const settingsPath = path.join(process.env.HOME || "~", ".pi", "agent", "settings.json");
|
|
114
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
115
|
+
piProvider = typeof settings.defaultProvider === "string" ? settings.defaultProvider : "";
|
|
116
|
+
piModel = typeof settings.defaultModel === "string" ? settings.defaultModel : "";
|
|
117
|
+
logger.ok(`preflight: pi found (${piBin}) — provider=${piProvider || "?"} model=${piModel || "?"}`);
|
|
118
|
+
} catch {
|
|
119
|
+
logger.warn(`pi found (${piBin}) but ~/.pi/agent/settings.json unreadable — set WORK_PI_PROVIDER manually`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
108
123
|
ensureSelfBinSymlink(logger);
|
|
109
124
|
|
|
110
125
|
if (isDevRepo()) {
|
|
@@ -184,6 +199,9 @@ export async function runInstall(
|
|
|
184
199
|
botName: opts.botName,
|
|
185
200
|
operatorLogin,
|
|
186
201
|
opencodeBin,
|
|
202
|
+
piBin,
|
|
203
|
+
piProvider,
|
|
204
|
+
piModel,
|
|
187
205
|
};
|
|
188
206
|
const webEnvResult = await ensureEnvFile({ file: "web", filePath: paths.webEnvFile, ctx });
|
|
189
207
|
if (webEnvResult.created) {
|
package/src/config.ts
CHANGED
|
@@ -28,6 +28,9 @@ export interface InstallContext {
|
|
|
28
28
|
operatorLogin: string;
|
|
29
29
|
// opencode binary path (looked up via preflight)
|
|
30
30
|
opencodeBin: string;
|
|
31
|
+
piBin: string;
|
|
32
|
+
piProvider: string;
|
|
33
|
+
piModel: string;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
export interface EnvKeySpec {
|
|
@@ -94,6 +97,10 @@ export const DAEMON_ENV_KEYS: readonly EnvKeySpec[] = [
|
|
|
94
97
|
{ envVar: "BOT_TOKEN", file: "daemon", secret: true, generate: () => "" },
|
|
95
98
|
{ envVar: "OPENCODE_BINARY", file: "daemon", generate: (c) => c.opencodeBin },
|
|
96
99
|
{ envVar: "OPENCODE_BASE_WORKDIR", file: "daemon", generate: (c) => c.paths.opencodeWorkdir },
|
|
100
|
+
{ envVar: "WORK_RUNTIME", file: "daemon", generate: () => "opencode" },
|
|
101
|
+
{ envVar: "WORK_PI_BINARY", file: "daemon", generate: (c) => c.piBin },
|
|
102
|
+
{ envVar: "WORK_PI_PROVIDER", file: "daemon", generate: (c) => c.piProvider },
|
|
103
|
+
{ envVar: "WORK_PI_DEFAULT_MODEL", file: "daemon", generate: (c) => c.piModel },
|
|
97
104
|
] as const;
|
|
98
105
|
|
|
99
106
|
export const ROUTER_ENV_KEYS: readonly EnvKeySpec[] = [
|
|
@@ -104,9 +111,9 @@ export const ROUTER_ENV_KEYS: readonly EnvKeySpec[] = [
|
|
|
104
111
|
{ envVar: "ROUTER_FALLBACK_ENDPOINT", file: "router", generate: (c) => `http://127.0.0.1:${c.daemonPort}` },
|
|
105
112
|
{ envVar: "ROUTER_CONFIG_FILE", file: "router", generate: (c) => `${c.paths.routerDataDir}/strategy.json` },
|
|
106
113
|
{ envVar: "WORK_DB_DRIVER", file: "router", generate: () => "sqlite" },
|
|
107
|
-
{ envVar: "WORK_DB_PATH", file: "router", generate: (c) => c.paths.
|
|
114
|
+
{ envVar: "WORK_DB_PATH", file: "router", generate: (c) => c.paths.daemonDbPath },
|
|
108
115
|
{ envVar: "WORK_DB_PREFIX", file: "router", generate: () => "" },
|
|
109
|
-
{ envVar: "DAEMON_TABLE_PREFIX", file: "router", generate: () => "
|
|
116
|
+
{ envVar: "DAEMON_TABLE_PREFIX", file: "router", generate: () => "" },
|
|
110
117
|
] as const;
|
|
111
118
|
|
|
112
119
|
export function keysForFile(file: EnvFile): readonly EnvKeySpec[] {
|
package/src/types.ts
CHANGED
|
@@ -72,6 +72,10 @@ export const SETTABLE_KEYS: readonly SettableKeySpec[] = [
|
|
|
72
72
|
{ key: "DAEMON_HOST", service: "daemon", description: "ework-daemon bind address (default 0.0.0.0 for multi-machine)" },
|
|
73
73
|
{ key: "OPENCODE_BINARY", service: "daemon", description: "opencode binary path" },
|
|
74
74
|
{ key: "OPENCODE_BASE_WORKDIR", service: "daemon", description: "opencode working directory base" },
|
|
75
|
+
{ key: "WORK_RUNTIME", service: "daemon", description: "agent runtime: opencode | pi (default opencode)" },
|
|
76
|
+
{ key: "WORK_PI_BINARY", service: "daemon", description: "pi binary path (used when WORK_RUNTIME=pi)" },
|
|
77
|
+
{ key: "WORK_PI_PROVIDER", service: "daemon", description: "pi provider name (used when WORK_RUNTIME=pi)" },
|
|
78
|
+
{ key: "WORK_PI_DEFAULT_MODEL", service: "daemon", description: "pi default model (used when WORK_RUNTIME=pi)" },
|
|
75
79
|
{ key: "COMPLETION_CHECK_API_KEY", service: "daemon", description: "completion-check API key" },
|
|
76
80
|
{ key: "COMPLETION_CHECK_BASE_URL", service: "daemon", description: "completion-check API base URL" },
|
|
77
81
|
{ key: "COMPLETION_CHECK_MODEL", service: "daemon", description: "completion-check model name" },
|