shraga 0.1.40 → 0.1.42
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.42",
|
|
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",
|
|
@@ -78,6 +78,18 @@ export function start(broadcast: Broadcast): void {
|
|
|
78
78
|
s.lastRun.error = 'interrupted by server restart';
|
|
79
79
|
}
|
|
80
80
|
if (!s.enabled) { s.nextRun = undefined; continue; }
|
|
81
|
+
// A one-off that already ran must never be armed again. It is normally DELETED on success,
|
|
82
|
+
// but schedules.json is a whole-file JSON array shared by this process and by humans over
|
|
83
|
+
// git — a conflicting rebase, or data-sync's union-merge conflict resolver, can bring the
|
|
84
|
+
// deleted entry back. If its `at` is still in the future the past-due guard below cannot
|
|
85
|
+
// see it, and a completed campaign send would go out twice. Refuse it here instead.
|
|
86
|
+
const ranBefore = oneShotAlreadyRan(s);
|
|
87
|
+
if (ranBefore) {
|
|
88
|
+
console.warn(`[scheduler] ⚠️ refusing to re-arm completed one-off ${s.id} ("${s.name}") — ${ranBefore}. Disabled; delete it or create a new schedule.`);
|
|
89
|
+
s.enabled = false;
|
|
90
|
+
s.nextRun = undefined;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
81
93
|
const next = computeNextRun(s.trigger);
|
|
82
94
|
if (next === null) {
|
|
83
95
|
if (s.trigger.kind === 'once') s.enabled = false;
|
|
@@ -164,6 +176,10 @@ export function getSchedule(id: string): Schedule | undefined {
|
|
|
164
176
|
export function upsertSchedule(s: Schedule): { ok: true; schedule: Schedule } | { ok: false; error: string } {
|
|
165
177
|
const err = validateTrigger(s.trigger);
|
|
166
178
|
if (err) return { ok: false, error: err };
|
|
179
|
+
// Same guard as boot: re-adding a spent one-off under its old id is a resurrection, not an
|
|
180
|
+
// edit. A genuinely new job gets a new id.
|
|
181
|
+
const ranBefore = oneShotAlreadyRan(s);
|
|
182
|
+
if (ranBefore) return { ok: false, error: `This one-off schedule already ran — ${ranBefore}. Create a new schedule instead of re-adding this one.` };
|
|
167
183
|
if (s.onMissed !== undefined && !MISSED_POLICIES.includes(s.onMissed)) {
|
|
168
184
|
return { ok: false, error: `Invalid onMissed "${s.onMissed}" (expected ${MISSED_POLICIES.join(' | ')})` };
|
|
169
185
|
}
|
|
@@ -361,6 +377,31 @@ export function resumeRun(scheduleId: string, sessionId: string, prompt: string)
|
|
|
361
377
|
|
|
362
378
|
// ── Internals ───────────────────────────────────────────────────────────────
|
|
363
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Evidence that a `once` schedule has ALREADY had its single run — i.e. this entry is a
|
|
382
|
+
* resurrection of one the engine deleted on success, not a new job. Returns a human-readable
|
|
383
|
+
* reason, or null when the schedule is not a spent one-off.
|
|
384
|
+
*
|
|
385
|
+
* Two independent witnesses, because neither survives everything:
|
|
386
|
+
* - the completion marker (data/scheduler/completions/<id>.json), which is per-id, gitignored
|
|
387
|
+
* and local to the firing instance — so a git merge can never carry it away, but a rebuilt
|
|
388
|
+
* data dir loses it;
|
|
389
|
+
* - the entry's own `lastRun.status === 'ok'`, which travels inside schedules.json — so it
|
|
390
|
+
* survives a wiped data dir, but only if the resurrected copy is from after that run.
|
|
391
|
+
*/
|
|
392
|
+
function oneShotAlreadyRan(s: Schedule): string | null {
|
|
393
|
+
if (s.trigger.kind !== 'once') return null;
|
|
394
|
+
const marker = readCompletionMarker(s.id);
|
|
395
|
+
// `completedAt` is the last SUCCESSFUL completion, and 0 when there has never been one. A
|
|
396
|
+
// marker is also written at fire time (`markRunStarted`) and on failure
|
|
397
|
+
// (`recordAttemptOutcome`), so its mere existence proves only an attempt. A future-dated
|
|
398
|
+
// one-off whose manual test-run errored has a marker but has never sent — retiring it would
|
|
399
|
+
// cancel a send that never happened, and report it as completed at epoch 0.
|
|
400
|
+
if (marker && marker.completedAt > 0) return `it completed at ${new Date(marker.completedAt).toISOString()} (${marker.triggeredBy})`;
|
|
401
|
+
if (s.lastRun?.status === 'ok') return `its own lastRun records a successful run at ${new Date(s.lastRun.at).toISOString()}`;
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
|
|
364
405
|
const MISSED_POLICIES: MissedPolicy[] = ['run', 'skip', 'offer'];
|
|
365
406
|
|
|
366
407
|
function refuse(reason: RunRefusal, message: string): RunOutcome {
|
|
@@ -27,6 +27,24 @@ BUN="${BUN:-bun}"
|
|
|
27
27
|
BOOT_TIMEOUT="${BOOT_TIMEOUT:-180}"
|
|
28
28
|
SOAK="${SOAK:-60}"
|
|
29
29
|
|
|
30
|
+
# ── Escape the service cgroup ─────────────────────────────────────────────────
|
|
31
|
+
# Under systemd the default KillMode=control-group kills EVERY process in the unit's cgroup on
|
|
32
|
+
# restart — and a detached child of the server is in that cgroup. So the supervisor would be killed
|
|
33
|
+
# by the very restart it triggers, taking the verify AND the rollback with it: exactly the guarantees
|
|
34
|
+
# it exists to provide. Re-exec into a transient scope (same uid, so `bun install` doesn't leave
|
|
35
|
+
# root-owned files) and we live outside the unit. Best-effort: where systemd-run or passwordless sudo
|
|
36
|
+
# is absent, carry on in-cgroup — an unsupervised upgrade still beats no upgrade.
|
|
37
|
+
if [ -z "${UPGRADE_DETACHED:-}" ]; then
|
|
38
|
+
export UPGRADE_DETACHED=1
|
|
39
|
+
if command -v systemd-run >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
|
|
40
|
+
escape=(sudo -n systemd-run --scope --quiet --collect --uid="$(id -u)" --gid="$(id -g)")
|
|
41
|
+
for v in APP_ROOT PKG TARGET FROM RESTART_CMD HEALTH_URL REPORT BUN BOOT_TIMEOUT SOAK PATH UPGRADE_DETACHED; do
|
|
42
|
+
escape+=("--setenv=$v=${!v-}")
|
|
43
|
+
done
|
|
44
|
+
exec "${escape[@]}" bash "$0"
|
|
45
|
+
fi
|
|
46
|
+
fi
|
|
47
|
+
|
|
30
48
|
cd "$APP_ROOT" || exit 1
|
|
31
49
|
BACKUP="$(mktemp -d)"
|
|
32
50
|
LOG="${REPORT%.json}.log"
|
|
@@ -43,6 +43,13 @@ function buildBlocks(id: string, questions: AskQuestion[]): unknown[] {
|
|
|
43
43
|
return blocks;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** Replace a question message's blocks with a final state, so its buttons stop looking live. */
|
|
47
|
+
async function retire(p: Pending, text: string) {
|
|
48
|
+
if (!p.messageTs) return;
|
|
49
|
+
await slackPost('chat.update', { channel: p.channel, ts: p.messageTs, text, blocks: [{ type: 'section', text: { type: 'mrkdwn', text } }] }, p.useUserToken)
|
|
50
|
+
.catch((err) => console.error(`${PREFIX} retire failed:`, (err as Error)?.message));
|
|
51
|
+
}
|
|
52
|
+
|
|
46
53
|
/** Build a QuestionHandler bound to a Slack channel/thread for the current turn. */
|
|
47
54
|
export function makeSlackQuestionHandler(ctx: Ctx): QuestionHandler {
|
|
48
55
|
return async (id, questions) => {
|
|
@@ -55,7 +62,12 @@ export function makeSlackQuestionHandler(ctx: Ctx): QuestionHandler {
|
|
|
55
62
|
console.log(`${PREFIX} posted question ${id} (${questions.length}q) to ${ctx.channel}`);
|
|
56
63
|
return new Promise<QuestionAnswers | null>((resolve) => {
|
|
57
64
|
const timer = setTimeout(() => {
|
|
58
|
-
|
|
65
|
+
const p = pending.get(id);
|
|
66
|
+
if (!p) return;
|
|
67
|
+
pending.delete(id);
|
|
68
|
+
console.log(`${PREFIX} ${id} timed out`);
|
|
69
|
+
void retire(p, '⌛ This question expired — I went ahead without an answer. Reply in the thread to steer me.');
|
|
70
|
+
resolve(null);
|
|
59
71
|
}, TTL_MS);
|
|
60
72
|
pending.set(id, { ...ctx, resolve, questions, messageTs: res.ts, timer });
|
|
61
73
|
});
|
|
@@ -98,14 +110,22 @@ export async function handleSlackInteraction(payload: any): Promise<boolean> {
|
|
|
98
110
|
if (!submit) return false;
|
|
99
111
|
const id = submit.value as string;
|
|
100
112
|
const p = pending.get(id);
|
|
101
|
-
if (!p)
|
|
113
|
+
if (!p) {
|
|
114
|
+
// Expired or lost to a restart: the buttons are still clickable but nothing is listening.
|
|
115
|
+
// Silence reads as a broken app, so always answer the click.
|
|
116
|
+
console.warn(`${PREFIX} submit for unknown/expired question ${id}`);
|
|
117
|
+
const channel = payload.channel?.id;
|
|
118
|
+
if (channel && userId) {
|
|
119
|
+
await slackPost('chat.postEphemeral', { channel, user: userId, text: '⌛ That question is no longer active (expired or I restarted). Just reply in the thread and I\'ll pick it up.' })
|
|
120
|
+
.catch((err) => console.error(`${PREFIX} ephemeral failed:`, (err as Error)?.message));
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
102
124
|
pending.delete(id);
|
|
103
125
|
clearTimeout(p.timer);
|
|
104
126
|
const answers = parseAnswers(payload.state?.values, p.questions);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
await slackPost('chat.update', { channel: p.channel, ts: p.messageTs, text: '✅ Got your answers.', blocks: [{ type: 'section', text: { type: 'mrkdwn', text: `✅ Got your answers:\n${summary}` } }] }, p.useUserToken).catch(() => {});
|
|
108
|
-
}
|
|
127
|
+
const summary = Object.entries(answers).map(([q, a]) => `• *${q}* — ${Array.isArray(a) ? a.join(', ') : a}`).join('\n') || '_(no selection)_';
|
|
128
|
+
await retire(p, `✅ Got your answers:\n${summary}`);
|
|
109
129
|
console.log(`${PREFIX} resolved question ${id} (${Object.keys(answers).length} answered)`);
|
|
110
130
|
p.resolve(Object.keys(answers).length ? answers : null);
|
|
111
131
|
return true;
|