shraga 0.1.50 → 0.1.52
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.
|
@@ -100,6 +100,9 @@ curl -s -H "$AUTH" -H 'content-type: application/json' \
|
|
|
100
100
|
therefore refused by design: this cannot fire unattended.
|
|
101
101
|
- **Check `blockers` first and relay them.** A source checkout (upgrade with git instead) and a
|
|
102
102
|
local dev symlink at `node_modules/shraga` are both refused on purpose — do not work around either.
|
|
103
|
+
- **A scheduled job mid-run also blocks it.** The restart would kill that run (SIGTERM/143), so the
|
|
104
|
+
default is to wait for idle. Say so and offer to retry shortly; only send `{"force":true}` if the
|
|
105
|
+
owner, told what is running, asks for it anyway.
|
|
103
106
|
- **202 means started, not succeeded.** The upgrade restarts the server, so the POST cannot report
|
|
104
107
|
the outcome. A detached supervisor installs, restarts, waits for `/api/version` to report the new
|
|
105
108
|
version, soaks it, and **reverts to the previous version automatically** if it doesn't hold.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.52",
|
|
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",
|
package/src/server/boot.ts
CHANGED
|
@@ -210,7 +210,7 @@ app.get('/api/self-upgrade', requireAuth, async (req, res) => {
|
|
|
210
210
|
app.post('/api/self-upgrade', requireAuth, async (req, res) => {
|
|
211
211
|
if (!(req as any).user?.isOwner) return void res.status(403).json({ error: 'Only the owner can manage upgrades' });
|
|
212
212
|
try {
|
|
213
|
-
const plan = await selfUpgrade.start({ version: req.body?.version });
|
|
213
|
+
const plan = await selfUpgrade.start({ version: req.body?.version, force: req.body?.force === true });
|
|
214
214
|
// 409, not 500: a refusal is a well-formed answer about the deployment's state, and the caller
|
|
215
215
|
// (often the agent, relaying to a human) needs the reason, not a stack trace.
|
|
216
216
|
res.status(plan.started ? 202 : 409).json(plan);
|
package/src/server/data-sync.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { readFile, stat } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { hostname } from 'node:os';
|
|
5
4
|
import { DATA_DIR } from './paths.ts';
|
|
6
|
-
import {
|
|
5
|
+
import { notifyOwners } from './notify-owners.ts';
|
|
7
6
|
import { runTextQuery } from './sdk-utils.ts';
|
|
8
7
|
|
|
9
8
|
const TAG = '[data-sync]';
|
|
@@ -576,24 +575,10 @@ export class DataSync {
|
|
|
576
575
|
console.warn(`${TAG} notification suppressed (not the authoritative instance):\n${text}`);
|
|
577
576
|
return;
|
|
578
577
|
}
|
|
579
|
-
|
|
580
|
-
// The Slack feature (slackFeature) subscribes and DMs each owner. Owner resolution uses the
|
|
581
|
-
// contacts store only, so data-sync stays transport-agnostic.
|
|
582
|
-
const { getAll } = await import('./contacts.ts');
|
|
583
|
-
const ownerEmails = (process.env.OWNERS ?? '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
584
|
-
const owners = getAll()
|
|
585
|
-
.filter(c => c.slackIds.length > 0 && c.emails.some(e => ownerEmails.includes(e.toLowerCase())))
|
|
586
|
-
.map(c => ({ name: c.name, slackId: c.slackIds[0] }));
|
|
587
|
-
if (!owners.length) {
|
|
588
|
-
console.warn(`${TAG} No owners (OWNERS env) with Slack IDs found, skipping notification`);
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
591
|
-
// Stamp the sender: every owner alert must name the instance it came from, so "is this back?"
|
|
592
|
-
// is answerable without log archaeology across hosts.
|
|
593
|
-
const from = `${this.options.deploymentId || 'shraga'}@${hostname()}`;
|
|
594
|
-
emitEvent('data-sync', { kind: 'deploy', owners, text: `${text}\n\n_from ${from}_` });
|
|
578
|
+
await notifyOwners('data-sync', text);
|
|
595
579
|
}
|
|
596
580
|
|
|
581
|
+
|
|
597
582
|
/**
|
|
598
583
|
* Routed through the Claude Code SDK (runTextQuery), same as the rest of the platform —
|
|
599
584
|
* authenticates via the CC subscription, no ANTHROPIC_API_KEY required. Past incident:
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Reaching a human, transport-agnostically. Resolve the deployment's owners from the contacts
|
|
2
|
+
// store and publish a "deploy notice" on the event bus; the Slack feature subscribes and DMs each
|
|
3
|
+
// one. Callers stay free of Slack (and of owner-resolution) entirely.
|
|
4
|
+
//
|
|
5
|
+
// This lives on its own because more than one subsystem needs it — data-sync's merge/integrity
|
|
6
|
+
// alerts and self-upgrade's outcome report — and the second one silently had NO delivery at all:
|
|
7
|
+
// it emitted an event nobody subscribed to, so a finished upgrade never reached anyone.
|
|
8
|
+
import { hostname } from 'node:os';
|
|
9
|
+
import { emitEvent } from './events/bus.ts';
|
|
10
|
+
|
|
11
|
+
export type Owner = { name?: string; slackId: string };
|
|
12
|
+
|
|
13
|
+
/** Owners of THIS deployment (OWNERS env ∩ contacts that have a Slack id). */
|
|
14
|
+
export async function resolveOwners(): Promise<Owner[]> {
|
|
15
|
+
const { getAll } = await import('./contacts.ts');
|
|
16
|
+
const ownerEmails = (process.env.OWNERS ?? '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
17
|
+
return getAll()
|
|
18
|
+
.filter(c => c.slackIds.length > 0 && c.emails.some(e => ownerEmails.includes(e.toLowerCase())))
|
|
19
|
+
.map(c => ({ name: c.name, slackId: c.slackIds[0] }));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `APP_NAME@host` — every owner-facing alert names the instance it came from, so "is this back?"
|
|
23
|
+
* is answerable without log archaeology across hosts. */
|
|
24
|
+
export function senderStamp(): string {
|
|
25
|
+
return `${process.env.APP_NAME || 'shraga'}@${hostname()}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* DM the owners. `source` is the event-bus source (used for logging/filtering only) — delivery is
|
|
30
|
+
* keyed on the notice `kind`, so a new subsystem needs no change on the Slack side.
|
|
31
|
+
* Returns false when there was nobody to tell.
|
|
32
|
+
*/
|
|
33
|
+
export async function notifyOwners(source: string, text: string): Promise<boolean> {
|
|
34
|
+
const owners = await resolveOwners();
|
|
35
|
+
if (!owners.length) {
|
|
36
|
+
console.warn(`[${source}] No owners (OWNERS env) with Slack IDs found, skipping notification`);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
emitEvent(source as any, { kind: 'deploy', owners, text: `${text}\n\n_from ${senderStamp()}_` });
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
@@ -14,6 +14,7 @@ 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';
|
|
17
|
+
import { notifyOwners } from '../notify-owners.ts';
|
|
17
18
|
|
|
18
19
|
const TAG = '[self-upgrade]';
|
|
19
20
|
const PKG = 'shraga';
|
|
@@ -60,8 +61,18 @@ export class SelfUpgrade {
|
|
|
60
61
|
* the running process disagreeing with no way to reconcile.
|
|
61
62
|
* - upgrade in flight → two supervisors racing on package.json is how you get a tree that
|
|
62
63
|
* matches neither version. */
|
|
63
|
-
public blockers(): string[] {
|
|
64
|
+
public blockers(opts: { force?: boolean } = {}): string[] {
|
|
64
65
|
const reasons: string[] = [];
|
|
66
|
+
|
|
67
|
+
// An upgrade restarts the process, which kills any scheduled run mid-flight — observed taking
|
|
68
|
+
// out the morning social recon (SIGTERM/143) during a routine upgrade. Wait for idle instead;
|
|
69
|
+
// `force` is the deliberate escape hatch for an owner who wants it now anyway.
|
|
70
|
+
if (!opts.force) {
|
|
71
|
+
const running = this.o.runningJobs();
|
|
72
|
+
if (running.length) {
|
|
73
|
+
reasons.push(`${running.length} scheduled job(s) still running (${running.join(', ')}) — restarting now would kill them mid-flight; retry when idle or pass {"force":true}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
65
76
|
const pkgPath = path.join(this.o.appRoot, 'package.json');
|
|
66
77
|
|
|
67
78
|
if (!existsSync(pkgPath)) {
|
|
@@ -113,11 +124,11 @@ export class SelfUpgrade {
|
|
|
113
124
|
* Start an upgrade. Resolves as soon as the supervisor is detached — the result arrives later, in
|
|
114
125
|
* the report file, because this process is about to be restarted by that supervisor.
|
|
115
126
|
*/
|
|
116
|
-
public async start(opts: { version?: string } = {}): Promise<UpgradePlan> {
|
|
127
|
+
public async start(opts: { version?: string; force?: boolean } = {}): Promise<UpgradePlan> {
|
|
117
128
|
const from = this.currentVersion();
|
|
118
129
|
const target = !opts.version || opts.version === 'latest' ? await this.latestVersion() : opts.version.replace(/^v/, '');
|
|
119
130
|
|
|
120
|
-
const blockers = this.blockers();
|
|
131
|
+
const blockers = this.blockers({ force: opts.force });
|
|
121
132
|
if (blockers.length) return { started: false, from, target, reason: blockers.join('; ') };
|
|
122
133
|
if (from === target) return { started: false, from, target, reason: `already on ${target}` };
|
|
123
134
|
if (!await this.versionExists(target)) return { started: false, from, target, reason: `${this.o.pkg}@${target} does not exist on the registry` };
|
|
@@ -191,6 +202,12 @@ export class SelfUpgrade {
|
|
|
191
202
|
|
|
192
203
|
console.log(`${TAG} ${report.status}: ${report.detail}`);
|
|
193
204
|
emitEvent('self-upgrade.finished', report);
|
|
205
|
+
// ...and actually tell a human. The event alone reached nobody: no subscriber existed, so every
|
|
206
|
+
// upgrade outcome — including `revert-failed`, which needs hands — was silently dropped.
|
|
207
|
+
const icon = report.status === 'ok' ? '✅' : report.status === 'reverted' ? '↩️' : '🚨';
|
|
208
|
+
notifyOwners('self-upgrade', `${icon} Self-upgrade ${report.status}: ${report.detail}\n\n` +
|
|
209
|
+
`${report.package} ${report.from} → ${report.target} (now on ${report.installed})\nLog: \`${report.log}\``)
|
|
210
|
+
.catch(err => console.warn(`${TAG} could not notify owners:`, (err as Error).message));
|
|
194
211
|
return report;
|
|
195
212
|
}
|
|
196
213
|
|
|
@@ -229,6 +246,14 @@ export class SelfUpgradeOptions {
|
|
|
229
246
|
public soakSec: number = 60;
|
|
230
247
|
/** After this, an in-flight marker is treated as abandoned (supervisor killed by a reboot). */
|
|
231
248
|
public maxRunMs: number = 30 * 60 * 1000;
|
|
249
|
+
/** Scheduled runs currently in flight. Lazy + guarded: the scheduler is not always mounted (and
|
|
250
|
+
* must not be imported at module load), and a preflight that throws is worse than one that
|
|
251
|
+
* assumes idle. Injectable so the preflight is testable without a running scheduler. */
|
|
252
|
+
public runningJobs: () => string[] = () => {
|
|
253
|
+
try { return require('../scheduler/engine.ts').getRunningIds() as string[]; }
|
|
254
|
+
catch (err) { console.warn(`${TAG} could not read running jobs:`, (err as Error).message); return []; }
|
|
255
|
+
};
|
|
256
|
+
|
|
232
257
|
/** Injectable so the preflight is testable without depending on the test host's PATH. */
|
|
233
258
|
public hasPython3: () => boolean = () => {
|
|
234
259
|
try { return spawnSync('python3', ['--version'], { stdio: 'ignore' }).status === 0; }
|
|
@@ -25,11 +25,12 @@ export const slackFeature: ServerFeature = {
|
|
|
25
25
|
|
|
26
26
|
if (!oauthMounted) { oauthMounted = true; registerSlackOAuthRoutes(ctx.app); }
|
|
27
27
|
|
|
28
|
-
//
|
|
28
|
+
// Owner notices arrive on the event bus (see notify-owners.ts — no subsystem couples to Slack);
|
|
29
|
+
// DM owners. Keyed on the notice KIND, not the source: self-upgrade emitted its outcome under
|
|
30
|
+
// its own source and a source-gated subscriber silently dropped every one of them.
|
|
29
31
|
if (!ctx.passive && !busSubscribed) {
|
|
30
32
|
busSubscribed = true;
|
|
31
33
|
subscribeEvents((evt) => {
|
|
32
|
-
if (evt.source !== 'data-sync') return;
|
|
33
34
|
const payload = evt.payload as DeployNotice;
|
|
34
35
|
if (payload?.kind !== 'deploy' || !payload.owners?.length) return;
|
|
35
36
|
for (const owner of payload.owners) {
|