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.50",
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",
@@ -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);
@@ -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 { emitEvent } from './events/bus.ts';
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
- // No Slack coupling here — resolve owner contacts and publish a deploy notice on the event bus.
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
- // Data-sync deploy notices arrive on the event bus (data-sync.ts has no Slack coupling); DM owners.
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) {