shraga 0.1.35 → 0.1.37
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.
|
@@ -86,12 +86,18 @@ List checkpoints with `git -C "$APP_DIR" log --oneline`; reset to any of them.
|
|
|
86
86
|
When the deployment consumes `shraga` as an npm dependency, you can move your own pinned version.
|
|
87
87
|
**Only when the user asks** — there is no timer on this.
|
|
88
88
|
|
|
89
|
+
Both routes need auth — use your internal token, as with any other `/api/` call:
|
|
90
|
+
|
|
89
91
|
```bash
|
|
90
|
-
|
|
91
|
-
curl -s -
|
|
92
|
+
AUTH="x-internal-token: $INTERNAL_API_TOKEN"
|
|
93
|
+
curl -s -H "$AUTH" http://localhost:$PORT/api/self-upgrade # current, latest, blockers
|
|
94
|
+
curl -s -H "$AUTH" -H 'content-type: application/json' \
|
|
95
|
+
-XPOST http://localhost:$PORT/api/self-upgrade -d '{"version":"latest"}' # or "0.1.35"
|
|
92
96
|
```
|
|
93
97
|
|
|
94
|
-
- **Owner only.**
|
|
98
|
+
- **Owner only.** Your internal token carries the identity of whoever you are acting for, so a 403
|
|
99
|
+
means *that person* is not an owner — not a bug. A scheduled run is `agent-internal` and is
|
|
100
|
+
therefore refused by design: this cannot fire unattended.
|
|
95
101
|
- **Check `blockers` first and relay them.** A source checkout (upgrade with git instead) and a
|
|
96
102
|
local dev symlink at `node_modules/shraga` are both refused on purpose — do not work around either.
|
|
97
103
|
- **202 means started, not succeeded.** The upgrade restarts the server, so the POST cannot report
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.37",
|
|
4
4
|
"description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -398,6 +398,18 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
398
398
|
if (!waitingForBg) { waitingForBg = true; clearBgTimer(); console.log(`[claude] Holding stream for ${outstandingTasks.size} bg tasks (${elapsed()})`); }
|
|
399
399
|
continue;
|
|
400
400
|
}
|
|
401
|
+
// The SDK reports `subtype: 'success'` even when the API call failed (e.g. an org spend
|
|
402
|
+
// cap → HTTP 400) — the only tells are `is_error` / `api_error_status`. Trusting the
|
|
403
|
+
// subtype makes an all-turns-failing agent look healthy: schedules store lastRun 'ok',
|
|
404
|
+
// the failure notifier stays silent, and Slack simply gets nothing. Surface it as an
|
|
405
|
+
// error so the run fails loudly.
|
|
406
|
+
// `error_max_turns` also sets is_error but is a benign stop reason we already model.
|
|
407
|
+
if ((m.is_error || m.api_error_status) && sub !== 'max_turns_reached') {
|
|
408
|
+
const detail = typeof m.result === 'string' && m.result ? m.result : `api_error_status=${m.api_error_status ?? '?'}`;
|
|
409
|
+
console.error(`[claude] API error result — ${detail}`);
|
|
410
|
+
yield { type: 'error', message: `Claude API error: ${detail}` };
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
401
413
|
if (textDeltaCount === 0) {
|
|
402
414
|
console.warn(`[claude] Empty response — result keys: ${Object.keys(m).join(',')}`);
|
|
403
415
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// entry point. Deployments that want it scheduled can point a schedule at that route, which keeps
|
|
11
11
|
// the "should I upgrade tonight?" policy out of the mechanism.
|
|
12
12
|
import { existsSync, lstatSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
13
|
-
import { spawn } from 'node:child_process';
|
|
13
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { APP_ROOT, PACKAGE_ROOT, dataPath } from '../paths.ts';
|
|
16
16
|
import { emitEvent } from '../events/bus.ts';
|
|
@@ -79,6 +79,18 @@ export class SelfUpgrade {
|
|
|
79
79
|
reasons.push('no restart command configured (RESTART_CMD / SHRAGA_RESTART_CMD) — an installed version could never be started');
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// The supervisor is detached with stdio ignored, so a missing script fails INVISIBLY: nothing
|
|
83
|
+
// happens, no report is ever written, and the in-flight marker sits there until it ages out.
|
|
84
|
+
if (!existsSync(this.o.supervisorScript)) {
|
|
85
|
+
reasons.push(`upgrade supervisor missing at ${this.o.supervisorScript}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The supervisor needs python3 to edit package.json. Better to say so now than to find out
|
|
89
|
+
// mid-upgrade, with the pin already rewritten.
|
|
90
|
+
if (!this.o.hasPython3()) {
|
|
91
|
+
reasons.push('python3 not found on PATH — the upgrade supervisor needs it to edit package.json');
|
|
92
|
+
}
|
|
93
|
+
|
|
82
94
|
const active = this.inFlight();
|
|
83
95
|
if (active) reasons.push(`an upgrade to ${active.target} is already in flight (started ${active.startedAt})`);
|
|
84
96
|
|
|
@@ -111,27 +123,34 @@ export class SelfUpgrade {
|
|
|
111
123
|
mkdirSync(path.dirname(this.o.reportFile), { recursive: true });
|
|
112
124
|
writeFileSync(this.o.lockFile, JSON.stringify({ target, startedAt: new Date().toISOString(), at: Date.now() }));
|
|
113
125
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
126
|
+
let child;
|
|
127
|
+
try {
|
|
128
|
+
// detached + ignored stdio: the supervisor must outlive us, and it will — we are its first
|
|
129
|
+
// casualty. It logs to a file of its own (see supervisor.sh).
|
|
130
|
+
child = spawn('bash', [this.o.supervisorScript], {
|
|
131
|
+
cwd: this.o.appRoot,
|
|
132
|
+
detached: true,
|
|
133
|
+
stdio: 'ignore',
|
|
134
|
+
env: {
|
|
135
|
+
...process.env,
|
|
136
|
+
APP_ROOT: this.o.appRoot,
|
|
137
|
+
PKG: this.o.pkg,
|
|
138
|
+
TARGET: target,
|
|
139
|
+
FROM: from ?? '',
|
|
140
|
+
RESTART_CMD: this.o.restartCmd,
|
|
141
|
+
HEALTH_URL: this.o.healthUrl,
|
|
142
|
+
REPORT: this.o.reportFile,
|
|
143
|
+
BUN: this.o.bun,
|
|
144
|
+
BOOT_TIMEOUT: String(this.o.bootTimeoutSec),
|
|
145
|
+
SOAK: String(this.o.soakSec),
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
child.unref();
|
|
149
|
+
} catch (err) {
|
|
150
|
+
// Nothing was started, so the marker would otherwise block every retry until it ages out.
|
|
151
|
+
try { unlinkSync(this.o.lockFile); } catch { /* best effort */ }
|
|
152
|
+
return { started: false, from, target, reason: `could not start the upgrade supervisor: ${(err as Error).message}` };
|
|
153
|
+
}
|
|
135
154
|
|
|
136
155
|
console.log(`${TAG} handed off ${this.o.pkg} ${from} -> ${target} to supervisor pid ${child.pid}`);
|
|
137
156
|
return { started: true, from, target, reason: `upgrading to ${target}; the result will be reported when the server comes back` };
|
|
@@ -178,6 +197,11 @@ export class SelfUpgradeOptions {
|
|
|
178
197
|
public soakSec: number = 60;
|
|
179
198
|
/** After this, an in-flight marker is treated as abandoned (supervisor killed by a reboot). */
|
|
180
199
|
public maxRunMs: number = 30 * 60 * 1000;
|
|
200
|
+
/** Injectable so the preflight is testable without depending on the test host's PATH. */
|
|
201
|
+
public hasPython3: () => boolean = () => {
|
|
202
|
+
try { return spawnSync('python3', ['--version'], { stdio: 'ignore' }).status === 0; }
|
|
203
|
+
catch { return false; }
|
|
204
|
+
};
|
|
181
205
|
}
|
|
182
206
|
|
|
183
207
|
export interface UpgradePlan {
|
|
@@ -32,6 +32,15 @@ BACKUP="$(mktemp -d)"
|
|
|
32
32
|
LOG="${REPORT%.json}.log"
|
|
33
33
|
exec >>"$LOG" 2>&1
|
|
34
34
|
|
|
35
|
+
# python3 edits package.json and escapes the report. Check it BEFORE anything is touched: discovering
|
|
36
|
+
# it missing halfway through means a rewritten pin we can no longer safely put back. SelfUpgrade
|
|
37
|
+
# .blockers() checks this too, so a deployment normally learns about it at request time, not here.
|
|
38
|
+
if ! command -v python3 >/dev/null 2>&1; then
|
|
39
|
+
printf '{"status":"failed","detail":"python3 not found on PATH — nothing was changed","from":"%s","target":"%s","installed":"%s","package":"%s","log":"%s","finishedAt":"%s"}\n' \
|
|
40
|
+
"$FROM" "$TARGET" "$FROM" "$PKG" "$LOG" "$(date -u '+%FT%TZ')" > "$REPORT"
|
|
41
|
+
exit 1
|
|
42
|
+
fi
|
|
43
|
+
|
|
35
44
|
ts() { date '+%F %T'; }
|
|
36
45
|
say() { echo "[upgrade] $(ts) $*"; }
|
|
37
46
|
|
|
@@ -67,15 +76,25 @@ restore() {
|
|
|
67
76
|
return 0
|
|
68
77
|
}
|
|
69
78
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
79
|
+
# Rewrite ONLY this dep's pin, leaving the rest of package.json byte-identical (a JSON round-trip
|
|
80
|
+
# would reformat a file the deployment owns). Text editing a structured file is only safe if it is
|
|
81
|
+
# unambiguous, so this refuses unless exactly ONE "<pkg>": "<version>" pair exists AND its current
|
|
82
|
+
# value is the one we expect — a package.json that also names the package under overrides/
|
|
83
|
+
# resolutions/peerDependencies must not be edited by guesswork.
|
|
84
|
+
pin() { # new-version expected-current-version
|
|
85
|
+
PKG="$PKG" NEW="$1" EXPECT="$2" python3 - <<'PY'
|
|
86
|
+
import os, re, sys
|
|
87
|
+
pkg, new, expect = os.environ['PKG'], os.environ['NEW'], os.environ['EXPECT']
|
|
74
88
|
src = open('package.json').read()
|
|
75
|
-
pat = re.compile(r'("%s"\s*:\s*")[^"]*(")' % re.escape(pkg))
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
89
|
+
pat = re.compile(r'("%s"\s*:\s*")([^"]*)(")' % re.escape(pkg))
|
|
90
|
+
hits = list(pat.finditer(src))
|
|
91
|
+
if len(hits) != 1:
|
|
92
|
+
sys.exit(f'expected exactly one "{pkg}" version entry in package.json, found {len(hits)}')
|
|
93
|
+
current = hits[0].group(2).lstrip('^~')
|
|
94
|
+
if expect and current != expect:
|
|
95
|
+
sys.exit(f'package.json has {pkg}@{hits[0].group(2)}, expected {expect} — refusing to edit')
|
|
96
|
+
m = hits[0]
|
|
97
|
+
open('package.json', 'w').write(src[:m.start()] + m.group(1) + new + m.group(3) + src[m.end():])
|
|
79
98
|
PY
|
|
80
99
|
}
|
|
81
100
|
|
|
@@ -108,9 +127,9 @@ soak() { # version seconds
|
|
|
108
127
|
return 0
|
|
109
128
|
}
|
|
110
129
|
|
|
111
|
-
install_and_restart() { # version label
|
|
112
|
-
say "installing $PKG@$1 ($
|
|
113
|
-
if ! pin "$1"; then say "pin failed"; return 1; fi
|
|
130
|
+
install_and_restart() { # version expected-current label
|
|
131
|
+
say "installing $PKG@$1 ($3)"
|
|
132
|
+
if ! pin "$1" "$2"; then say "pin failed"; return 1; fi
|
|
114
133
|
if ! "$BUN" install; then say "bun install failed"; return 1; fi
|
|
115
134
|
say "restarting via: $RESTART_CMD"
|
|
116
135
|
eval "$RESTART_CMD" || say "restart command returned non-zero (continuing — some managers do)"
|
|
@@ -124,7 +143,7 @@ if ! snapshot; then
|
|
|
124
143
|
exit 1
|
|
125
144
|
fi
|
|
126
145
|
|
|
127
|
-
if ! install_and_restart "$TARGET" upgrade; then
|
|
146
|
+
if ! install_and_restart "$TARGET" "$FROM" upgrade; then
|
|
128
147
|
restore && "$BUN" install >/dev/null 2>&1
|
|
129
148
|
write_report failed "install of $PKG@$TARGET failed; package.json restored, service untouched" "$FROM"
|
|
130
149
|
exit 1
|
|
@@ -142,7 +161,7 @@ if ! restore; then
|
|
|
142
161
|
write_report revert-failed "upgrade to $TARGET failed AND the backup could not be restored — MANUAL FIX NEEDED. Backup: $BACKUP" unknown
|
|
143
162
|
exit 1
|
|
144
163
|
fi
|
|
145
|
-
if ! install_and_restart "$FROM" revert; then
|
|
164
|
+
if ! install_and_restart "$FROM" "" revert; then
|
|
146
165
|
write_report revert-failed "upgrade to $TARGET failed and reinstalling $FROM ALSO failed — MANUAL FIX NEEDED. Backup: $BACKUP" unknown
|
|
147
166
|
exit 1
|
|
148
167
|
fi
|