shraga 0.1.25 → 0.1.27
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.
|
@@ -156,7 +156,7 @@ For **vendor webhooks** that can't send shraga auth (Stripe, GitHub, …), add a
|
|
|
156
156
|
|
|
157
157
|
The system emits these onto the bus automatically — use them as the `source` of an event trigger to react to the agent's own lifecycle:
|
|
158
158
|
|
|
159
|
-
- **`schedule.finished`** — fired when any time/manual schedule run completes. Payload: `{ scheduleId, name, status, sessionId, sessionUrl?, error? }`. `status` is `ok` | `error` | `aborted
|
|
159
|
+
- **`schedule.finished`** — fired when any time/manual schedule run completes. Payload: `{ scheduleId, name, status, sessionId, sessionUrl?, error? }`. `status` is `ok` | `error` | `aborted` — `aborted` covers both a user cancel and a run killed by a signal (a deploy/restart SIGTERMs in-flight jobs), so alerts matching `error` don't fire on interrupted runs. Chain automations off it, e.g.:
|
|
160
160
|
```json
|
|
161
161
|
{ "trigger": { "kind": "event", "source": "schedule.finished", "match": { "status": "error" } },
|
|
162
162
|
"task": { "kind": "prompt", "prompt": "A scheduled run failed — investigate and post a summary." } }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.27",
|
|
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/claude.ts
CHANGED
|
@@ -74,7 +74,7 @@ export type WsEvent =
|
|
|
74
74
|
| { type: 'text_delta'; text: string }
|
|
75
75
|
| { type: 'tool_use'; tool: string; toolUseId: string; input: unknown }
|
|
76
76
|
| { type: 'tool_use_input'; toolUseId: string; input: unknown }
|
|
77
|
-
| { type: 'tool_result'; toolUseId: string; output: string }
|
|
77
|
+
| { type: 'tool_result'; toolUseId: string; output: string; isError?: boolean }
|
|
78
78
|
| { type: 'tool_result_image'; toolUseId: string; dataUrl: string }
|
|
79
79
|
| { type: 'permission_request'; id: string; tool: string; input: Record<string, unknown> }
|
|
80
80
|
| { type: 'question_request'; id: string; questions: AskQuestion[] }
|
|
@@ -446,7 +446,9 @@ export class ClaudeCodeEngine implements AgentEngine {
|
|
|
446
446
|
const output = contentArr.length > 0
|
|
447
447
|
? contentArr.filter((c: any) => c.type === 'text').map((c: any) => c.text ?? '').join('')
|
|
448
448
|
: String(block.content ?? '');
|
|
449
|
-
|
|
449
|
+
// `is_error` is the ONLY signal that a tool failed — the text alone is indistinguishable
|
|
450
|
+
// from a successful result. Scheduled `bash` tasks rely on it to notice a non-zero exit.
|
|
451
|
+
yield { type: 'tool_result', toolUseId: String(block.tool_use_id), output, isError: block.is_error === true };
|
|
450
452
|
|
|
451
453
|
let foundImage = false;
|
|
452
454
|
for (const c of contentArr) {
|
package/src/server/mcp.ts
CHANGED
|
@@ -52,11 +52,52 @@ function isHttpConfig(s: McpServerConfig): s is McpHttpServerConfig {
|
|
|
52
52
|
return s.type === 'http';
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/** `$VAR` / `${VAR}` placeholders inside a string. Same dialect as stdio `env` values, but also
|
|
56
|
+
* substitutes when EMBEDDED (`Bearer $TOKEN`) — a header value is never the bare variable. */
|
|
57
|
+
const PLACEHOLDER_RE = /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g;
|
|
58
|
+
|
|
59
|
+
/** Expand placeholders; unset var → '' (same as stdio env resolution). `found` records each hit. */
|
|
60
|
+
function expandPlaceholders(value: string, found: string[]): string {
|
|
61
|
+
return value.replace(PLACEHOLDER_RE, (_m, braced, bare) => {
|
|
62
|
+
const v = process.env[braced || bare] || '';
|
|
63
|
+
found.push(v);
|
|
64
|
+
return v;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Resolve `$VAR` placeholders in an http entry's headers and url.
|
|
69
|
+
* Without this, an authenticated remote MCP can only be configured by INLINING the secret into
|
|
70
|
+
* data/mcps/*.json — which shraga auto-commits and pushes to the data repo (see saveMcpConfig →
|
|
71
|
+
* dataSync.trackWrite). Entries with no placeholders are returned untouched. */
|
|
72
|
+
function resolveHttpConfig(name: string, server: McpHttpServerConfig): McpHttpServerConfig | null {
|
|
73
|
+
const found: string[] = [];
|
|
74
|
+
const out: McpHttpServerConfig = { ...server };
|
|
75
|
+
if (typeof server.url === 'string') out.url = expandPlaceholders(server.url, found);
|
|
76
|
+
if (server.headers) {
|
|
77
|
+
const headers: Record<string, string> = {};
|
|
78
|
+
for (const [k, v] of Object.entries(server.headers)) {
|
|
79
|
+
headers[k] = typeof v === 'string' ? expandPlaceholders(v, found) : String(v ?? '');
|
|
80
|
+
}
|
|
81
|
+
out.headers = headers;
|
|
82
|
+
}
|
|
83
|
+
// Env-gate, mirroring stdio: an entry that DECLARES placeholders but resolves them all empty isn't
|
|
84
|
+
// configured for this deployment — skip it rather than mounting with a broken `Bearer ` credential.
|
|
85
|
+
if (found.length > 0 && found.every((v) => !v)) {
|
|
86
|
+
console.log(`[mcp] ${name}: skipped — required env placeholders not set in this deployment`);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
55
92
|
/** Resolve env values: empty → process.env[same key], "$VAR" → process.env[VAR] */
|
|
56
93
|
function resolveEnv(config: McpConfig): McpConfig {
|
|
57
94
|
const resolved: McpConfig = {};
|
|
58
95
|
for (const [name, server] of Object.entries(config)) {
|
|
59
|
-
if (isHttpConfig(server)) {
|
|
96
|
+
if (isHttpConfig(server)) {
|
|
97
|
+
const http = resolveHttpConfig(name, server);
|
|
98
|
+
if (http) resolved[name] = http;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
60
101
|
if (!server.env) { resolved[name] = server; continue; }
|
|
61
102
|
const env: Record<string, string> = {};
|
|
62
103
|
for (const [k, v] of Object.entries(server.env)) {
|
|
@@ -194,12 +194,21 @@ export async function runSchedule(
|
|
|
194
194
|
let status: ScheduleRunSummary['status'] = 'ok';
|
|
195
195
|
let error: string | undefined;
|
|
196
196
|
|
|
197
|
+
// A `bash` task is not exec'd — it's handed to the agent as a prompt, so the command's exit code
|
|
198
|
+
// reaches nobody: the agent reports the failure in prose, its own turn succeeds, the run is stored
|
|
199
|
+
// `ok`, and the failure notifier never fires. Track the tool_result of the task's OWN Bash call and
|
|
200
|
+
// fail the run with it, so a broken scheduled script alerts like a `job` does.
|
|
201
|
+
const taskBashToolUseIds = new Set<string>();
|
|
202
|
+
let bashFailure: string | undefined;
|
|
203
|
+
|
|
197
204
|
onEvent({ type: 'schedule:run_started', scheduleId: schedule.id, sessionId, at: now });
|
|
198
205
|
|
|
199
206
|
try {
|
|
200
207
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
201
208
|
status = 'ok';
|
|
202
209
|
error = undefined;
|
|
210
|
+
bashFailure = undefined;
|
|
211
|
+
taskBashToolUseIds.clear();
|
|
203
212
|
try {
|
|
204
213
|
for await (const ev of streamChat({
|
|
205
214
|
prompt,
|
|
@@ -217,10 +226,12 @@ export async function runSchedule(
|
|
|
217
226
|
} else if (ev.type === 'tool_use') {
|
|
218
227
|
if (assistantText) { assistantBlocks.push({ type: 'text', text: assistantText }); assistantText = ''; }
|
|
219
228
|
assistantBlocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
|
|
229
|
+
if (allowedCmd && ev.tool === 'Bash' && (ev.input as any)?.command === allowedCmd) taskBashToolUseIds.add(ev.toolUseId);
|
|
220
230
|
} else if (ev.type === 'thinking_delta') {
|
|
221
231
|
producedThinking = true;
|
|
222
232
|
} else if (ev.type === 'tool_result') {
|
|
223
233
|
assistantBlocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
|
|
234
|
+
if (ev.isError && taskBashToolUseIds.has(ev.toolUseId)) bashFailure = ev.output;
|
|
224
235
|
} else if (ev.type === 'done') {
|
|
225
236
|
break;
|
|
226
237
|
} else if (ev.type === 'error') {
|
|
@@ -238,6 +249,12 @@ export async function runSchedule(
|
|
|
238
249
|
}
|
|
239
250
|
}
|
|
240
251
|
|
|
252
|
+
// The agent turn can succeed while the command it was asked to run failed — that's the run failing.
|
|
253
|
+
if (status === 'ok' && bashFailure) {
|
|
254
|
+
status = 'error';
|
|
255
|
+
error = `Scheduled command failed:\n${bashFailure.slice(0, 4000)}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
241
258
|
if (status !== 'error') break;
|
|
242
259
|
|
|
243
260
|
// The side-effect boundary. This is NOT an exact `ttft=-1` test — the engine emits events we
|
|
@@ -338,14 +355,20 @@ async function runJobSchedule(
|
|
|
338
355
|
blocks: [{ type: 'text', text: `✅ Job completed successfully.\n\n${prefix}${output.trim() || '(no output)'}\n\`\`\`` }],
|
|
339
356
|
});
|
|
340
357
|
} catch (err: any) {
|
|
341
|
-
|
|
358
|
+
// A job killed by a SIGNAL was INTERRUPTED, not failed — the usual cause is systemd stopping the
|
|
359
|
+
// service (a deploy/restart), which SIGTERMs the whole cgroup including in-flight job children.
|
|
360
|
+
// Reporting that as `error` fires the failure notifier with an alert nobody can act on, whose
|
|
361
|
+
// "error" is just the job's own progress output (the process never got to throw anything).
|
|
362
|
+
// `aborted` is the existing status for "ended without failing" and the notifier ignores it.
|
|
363
|
+
const killed = typeof err?.signal === 'string';
|
|
364
|
+
status = abortController.signal.aborted || killed ? 'aborted' : 'error';
|
|
342
365
|
error = err?.message ?? String(err);
|
|
343
366
|
appendMessage(sessionId, {
|
|
344
367
|
id: crypto.randomUUID(),
|
|
345
368
|
role: 'assistant',
|
|
346
|
-
blocks: [{ type: 'text', text:
|
|
369
|
+
blocks: [{ type: 'text', text: `${killed ? '⏹️ Job interrupted' : '❌ Job failed'}.\n\n${prefix}${error}\n\`\`\`` }],
|
|
347
370
|
});
|
|
348
|
-
console
|
|
371
|
+
console[killed ? 'warn' : 'error'](`[scheduler] job run ${killed ? 'interrupted' : 'error'} for ${schedule.id}:`, error);
|
|
349
372
|
} finally {
|
|
350
373
|
clearInterval(partialInterval);
|
|
351
374
|
unregisterLivePartial(sessionId);
|
|
@@ -395,10 +418,19 @@ function runCommandWithMarker(command: string, abortController: AbortController,
|
|
|
395
418
|
|
|
396
419
|
abortController.signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
|
|
397
420
|
child.on('error', reject);
|
|
398
|
-
child.on('close', (code) => {
|
|
421
|
+
child.on('close', (code, signal) => {
|
|
399
422
|
const trimmed = output.trim();
|
|
400
|
-
if (code === 0) resolve(trimmed);
|
|
401
|
-
|
|
423
|
+
if (code === 0) return resolve(trimmed);
|
|
424
|
+
// Always state HOW it ended. The old form used the command's own output as the message whenever
|
|
425
|
+
// it had produced any, so a killed job reported its progress logs as "the error" and the actual
|
|
426
|
+
// cause (exit code / signal) was lost — the failure was captured, but not why.
|
|
427
|
+
const how = signal ? `killed by ${signal}` : `exit code ${code}`;
|
|
428
|
+
const err: NodeJS.ErrnoException & { signal?: string } = new Error(
|
|
429
|
+
trimmed ? `Command ${how}. Output:\n${trimmed}` : `Command ${how}`,
|
|
430
|
+
);
|
|
431
|
+
// Carried so callers can tell an interrupted run (deploy/restart) from a real failure.
|
|
432
|
+
if (signal) err.signal = signal;
|
|
433
|
+
reject(err);
|
|
402
434
|
});
|
|
403
435
|
});
|
|
404
436
|
}
|