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.
|
|
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",
|
package/src/server/data-sync.ts
CHANGED
|
@@ -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 {
|
|
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()
|
|
120
|
-
console.warn(`${TAG} Post-init conflict scan failed:`, (err as Error).message)
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
446
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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) {
|