shraga 0.1.49 → 0.1.51

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.49",
3
+ "version": "0.1.51",
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",
@@ -1,8 +1,8 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
2
+ import { readFile, stat } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
- import { hostname } from 'node:os';
4
4
  import { DATA_DIR } from './paths.ts';
5
- import { emitEvent } from './events/bus.ts';
5
+ import { notifyOwners } from './notify-owners.ts';
6
6
  import { runTextQuery } from './sdk-utils.ts';
7
7
 
8
8
  const TAG = '[data-sync]';
@@ -116,10 +116,12 @@ export class DataSync {
116
116
  // Defer heavy sync I/O (reads all tracked files + execSync) to avoid blocking
117
117
  // WS connections and page loads during startup.
118
118
  setTimeout(() => {
119
- this.scanForConflictMarkers().catch(err => {
120
- console.warn(`${TAG} Post-init conflict scan failed:`, (err as Error).message);
121
- });
122
- this.runIntegrityAudit();
119
+ this.scanForConflictMarkers()
120
+ .catch(err => console.warn(`${TAG} Post-init conflict scan failed:`, (err as Error).message))
121
+ // execSync inside the audit blocks too — keep it off the scan's tick so the two
122
+ // never add up into one long freeze.
123
+ .then(() => new Promise<void>(r => setImmediate(r)))
124
+ .then(() => this.runIntegrityAudit());
123
125
  }, 60_000);
124
126
  }
125
127
 
@@ -442,13 +444,20 @@ export class DataSync {
442
444
  try {
443
445
  const tracked = (await this.git('ls-files')).split('\n').filter(Boolean);
444
446
  const conflicted: string[] = [];
445
- for (const f of tracked) {
446
- const abs = path.join(DATA_DIR, f);
447
+ // ASYNC + YIELDING on purpose. This walks every tracked file (1000+, GBs on a real
448
+ // deployment); doing it synchronously froze the event loop for ~45s — the server stopped
449
+ // answering /api/version entirely, which also made self-upgrade verification fail and
450
+ // auto-revert healthy versions. Deferring a synchronous freeze only moves it; it has to
451
+ // not block at all.
452
+ for (let i = 0; i < tracked.length; i++) {
453
+ const abs = path.join(DATA_DIR, tracked[i]);
447
454
  try {
448
- if (!existsSync(abs) || statSync(abs).isDirectory()) continue;
449
- const content = readFileSync(abs, 'utf-8');
450
- if (/^<{7} /m.test(content)) conflicted.push(f);
455
+ const info = await stat(abs).catch(() => null);
456
+ if (!info || info.isDirectory()) continue;
457
+ const content = await readFile(abs, 'utf-8');
458
+ if (/^<{7} /m.test(content)) conflicted.push(tracked[i]);
451
459
  } catch { /* skip unreadable */ }
460
+ if (i % 25 === 24) await new Promise<void>(r => setImmediate(r)); // let the server breathe
452
461
  }
453
462
  if (!conflicted.length) return;
454
463
 
@@ -566,24 +575,10 @@ export class DataSync {
566
575
  console.warn(`${TAG} notification suppressed (not the authoritative instance):\n${text}`);
567
576
  return;
568
577
  }
569
- // No Slack coupling here — resolve owner contacts and publish a deploy notice on the event bus.
570
- // The Slack feature (slackFeature) subscribes and DMs each owner. Owner resolution uses the
571
- // contacts store only, so data-sync stays transport-agnostic.
572
- const { getAll } = await import('./contacts.ts');
573
- const ownerEmails = (process.env.OWNERS ?? '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
574
- const owners = getAll()
575
- .filter(c => c.slackIds.length > 0 && c.emails.some(e => ownerEmails.includes(e.toLowerCase())))
576
- .map(c => ({ name: c.name, slackId: c.slackIds[0] }));
577
- if (!owners.length) {
578
- console.warn(`${TAG} No owners (OWNERS env) with Slack IDs found, skipping notification`);
579
- return;
580
- }
581
- // Stamp the sender: every owner alert must name the instance it came from, so "is this back?"
582
- // is answerable without log archaeology across hosts.
583
- const from = `${this.options.deploymentId || 'shraga'}@${hostname()}`;
584
- emitEvent('data-sync', { kind: 'deploy', owners, text: `${text}\n\n_from ${from}_` });
578
+ await notifyOwners('data-sync', text);
585
579
  }
586
580
 
581
+
587
582
  /**
588
583
  * Routed through the Claude Code SDK (runTextQuery), same as the rest of the platform —
589
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';
@@ -191,6 +192,12 @@ export class SelfUpgrade {
191
192
 
192
193
  console.log(`${TAG} ${report.status}: ${report.detail}`);
193
194
  emitEvent('self-upgrade.finished', report);
195
+ // ...and actually tell a human. The event alone reached nobody: no subscriber existed, so every
196
+ // upgrade outcome — including `revert-failed`, which needs hands — was silently dropped.
197
+ const icon = report.status === 'ok' ? '✅' : report.status === 'reverted' ? '↩️' : '🚨';
198
+ notifyOwners('self-upgrade', `${icon} Self-upgrade ${report.status}: ${report.detail}\n\n` +
199
+ `${report.package} ${report.from} → ${report.target} (now on ${report.installed})\nLog: \`${report.log}\``)
200
+ .catch(err => console.warn(`${TAG} could not notify owners:`, (err as Error).message));
194
201
  return report;
195
202
  }
196
203
 
@@ -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) {