shraga 0.1.31 → 0.1.33
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/defaults/skills/mcp-server.md +1 -0
- package/defaults/skills/scheduler.md +105 -1
- package/defaults/skills/self-aware.md +12 -1
- package/package.json +1 -1
- package/src/server/boot.ts +51 -4
- package/src/server/downtime.ts +451 -0
- package/src/server/mcp-server.ts +19 -2
- package/src/server/scheduler/engine.ts +256 -42
- package/src/server/scheduler/runner.ts +6 -6
- package/src/server/scheduler/storage.ts +74 -0
- package/src/server/scheduler/types.ts +65 -0
- package/src/server/slack/bot.ts +5 -0
|
@@ -56,6 +56,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (uses s
|
|
|
56
56
|
| `put_skills_write` | Create/update a skill |
|
|
57
57
|
| `get_schedules` | List scheduled jobs |
|
|
58
58
|
| `post_schedules_run` | Trigger a schedule run |
|
|
59
|
+
| `get_downtime` | "What did I miss?" — outage ranges + missed schedule windows + Slack backfill (reports/proposes only, fires nothing) |
|
|
59
60
|
| `post_chat` | Talk to the agent — conversational, multi-turn via sessionId |
|
|
60
61
|
| `get_config` | Read agent configuration |
|
|
61
62
|
|
|
@@ -134,9 +134,113 @@ Task:
|
|
|
134
134
|
{ kind: "prompt", prompt: "<text>" }
|
|
135
135
|
{ kind: "bash", command: "<cmd>" }
|
|
136
136
|
|
|
137
|
-
Schedule: { id, name, enabled, trigger, task, scope, createdBy, nextRun?, lastRun?, runCount
|
|
137
|
+
Schedule: { id, name, enabled, trigger, task, scope, createdBy, nextRun?, lastRun?, runCount,
|
|
138
|
+
onMissed?, missedRun? }
|
|
138
139
|
```
|
|
139
140
|
|
|
141
|
+
## Missed windows (`onMissed` / `missedRun`)
|
|
142
|
+
|
|
143
|
+
If a window elapses with nothing running (deploy, crash, power cut — or the host **suspending**
|
|
144
|
+
with the process frozen), the scheduler decides whether to replay it. `onMissed` is settable on
|
|
145
|
+
`POST`/`PUT`:
|
|
146
|
+
|
|
147
|
+
| `onMissed` | Behaviour |
|
|
148
|
+
|---|---|
|
|
149
|
+
| `run` (default) | Replay the missed window — the pre-existing behaviour. |
|
|
150
|
+
| `skip` | Never replay. The window is simply lost. |
|
|
151
|
+
| `offer` | Don't auto-fire, but **record** it so a human/agent can run it on demand. |
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
curl -s -X PUT -H "Content-Type: application/json" -H "x-internal-token: $INTERNAL_API_TOKEN" \
|
|
155
|
+
http://localhost:$PORT/api/schedules/{id} -d '{ "onMissed": "offer" }' | jq .
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
- **A staleness ceiling overrides every policy, `run` included**: a window more than
|
|
159
|
+
`SCHEDULER_MAX_MISSED_AGE_MS` (default **6h**) late is never replayed. Finishing an 08:00 report
|
|
160
|
+
at 22:00 is not the job the schedule describes.
|
|
161
|
+
- One decision (`judgeMissed` in `scheduler/engine.ts`) governs **all three** paths that could
|
|
162
|
+
replay a window:
|
|
163
|
+
1. **Boot catch-up** (cron only) — the process was down when the window passed.
|
|
164
|
+
2. **In-place resume** of a run interrupted mid-flight — every trigger kind
|
|
165
|
+
(`interval`/`once`/`event` have no catch-up, so resume is their only gate; their window is
|
|
166
|
+
the one the interrupted run recorded when it started).
|
|
167
|
+
3. **A late timer fire** — the process was *frozen*, not killed (laptop lid/standby, NTP step,
|
|
168
|
+
VM pause): same pid, no restart, so catch-up never ran, and the overdue `setTimeout` fires on
|
|
169
|
+
wake. The window is the schedule's `nextRun` (when it *should* have fired), not the wake time.
|
|
170
|
+
A punctual fire is untouched — `onMissed` is a missed-window policy, never a mute — the gate
|
|
171
|
+
engages only once the fire is past the ceiling.
|
|
172
|
+
- After a refusal the schedule is still re-armed normally: an `interval` job loses exactly one
|
|
173
|
+
cycle and fires again next period; a `once` is retired (disabled, `nextRun` cleared) with its
|
|
174
|
+
`missedRun` left for an on-demand run. Nothing is left enabled-but-never-firing.
|
|
175
|
+
- Whenever a window is not replayed, the schedule gets
|
|
176
|
+
`missedRun: { at, reason: "skip"|"offer"|"stale", noticedAt }` — visible on
|
|
177
|
+
`GET /api/schedules` and `GET /api/schedules/{id}`. **That is the `offer` affordance**: read it,
|
|
178
|
+
then `POST /api/schedules/{id}/run` to run the missed work on demand. It clears the moment the
|
|
179
|
+
schedule next starts a run.
|
|
180
|
+
- `POST /api/schedules/{id}/run` returns **409** `{ error, reason }` when a run can't start
|
|
181
|
+
(`locked` — a run is already in flight, incl. one held across a restart; `already-completed` /
|
|
182
|
+
`already-attempted`). A `404` there really does mean the schedule doesn't exist.
|
|
183
|
+
|
|
184
|
+
## "What did I miss?" — downtime recovery (`GET /api/downtime`)
|
|
185
|
+
|
|
186
|
+
This box is deliberately not always-on. The server heartbeats to `data/state/heartbeat.json` every
|
|
187
|
+
`HEARTBEAT_INTERVAL_MS` (default **60s**). A gap larger than `DOWNTIME_THRESHOLD_MS` (default
|
|
188
|
+
**3 intervals = 3m**) is appended to `data/state/downtime.json` as
|
|
189
|
+
`{ from, to, ms, cause, slackCursors? }` (last **20** kept). A clean restart records nothing.
|
|
190
|
+
|
|
191
|
+
Two `cause`s, one code path:
|
|
192
|
+
|
|
193
|
+
- **`cause: 'boot'`** — the process died (crash, power cut). The gap is measured at startup, from
|
|
194
|
+
the last persisted heartbeat.
|
|
195
|
+
- **`cause: 'suspend'`** — the process never died: the **host slept** (lid closed, standby) and the
|
|
196
|
+
process was *frozen*. Nothing restarts, so a boot check would never see it — instead the next
|
|
197
|
+
heartbeat tick comes back late by more than the threshold and records the gap itself. One entry
|
|
198
|
+
per outage, however long (the tick writes the heartbeat forward before the next one compares).
|
|
199
|
+
Two consequences of a `suspend` gap, both flagged in the report's `note`:
|
|
200
|
+
|
|
201
|
+
- **`missedSchedules[]` is INCOMPLETE, not empty** — the boot-time catch-up scan never ran (nothing
|
|
202
|
+
restarted), so the only records are the ones the **late timer fire** wrote itself (see "Missed
|
|
203
|
+
windows" above: a fire past the ceiling is refused and recorded as `missedRun`, at a timestamp
|
|
204
|
+
*inside* the gap, which the report then joins). Windows still inside the ceiling on wake simply
|
|
205
|
+
ran; anything else that elapsed in the gap left **no record at all** — check those by hand. The
|
|
206
|
+
note is emitted for any suspend in the last `SUSPEND_NOTE_MAX_AGE_MS` (**24h**), not just the
|
|
207
|
+
newest entry: a restart afterwards appends a `boot` entry but rescans nothing.
|
|
208
|
+
- **No `slackCursors` snapshot** — the snapshot is only taken on the `boot` path, where it is
|
|
209
|
+
provably older than any post-recovery traffic. A suspend gap is recorded from a heartbeat tick up
|
|
210
|
+
to 60s *after* the wake, by which time the reconnected socket may already have pushed the cursor
|
|
211
|
+
past the whole backlog. So the entry carries none and the backfill floors at the gap's `from` —
|
|
212
|
+
it may re-report a few already-seen messages, never skip unseen ones.
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
curl -s -H "x-internal-token: $INTERNAL_API_TOKEN" "http://localhost:$PORT/api/downtime" | jq .
|
|
216
|
+
curl -s -H "x-internal-token: $INTERNAL_API_TOKEN" "http://localhost:$PORT/api/downtime?slack=0" | jq . # skip the Slack read
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Returns `{ heartbeat, downtime[], lastDowntime, missedSchedules[], slack }`:
|
|
220
|
+
|
|
221
|
+
- **`missedSchedules[]`** — the `missedRun` records above, joined to the outage that covers them,
|
|
222
|
+
each with a `proposal` string. It does **not** re-derive which windows were missed; the
|
|
223
|
+
scheduler owns that.
|
|
224
|
+
- **`slack`** — messages that arrived during the last outage, fetched **on demand only** (never at
|
|
225
|
+
boot) via `conversations.history`, deduped by `client_msg_id`. History, not event replay: Slack's
|
|
226
|
+
Events API retries a dropped delivery for only ~30 minutes then discards it forever, while
|
|
227
|
+
`conversations.history` stays readable for days.
|
|
228
|
+
- **Where it starts:** the outage's `from`, raised only by the last-seen cursor **as
|
|
229
|
+
snapshotted into the downtime entry at boot** (`slackCursors`, `cause: 'boot'` only — a
|
|
230
|
+
`suspend` entry has none, so it starts at `from`). The live cursors in
|
|
231
|
+
`data/state/slack-cursors.json` are a tail pointer — one message after recovery pushes them
|
|
232
|
+
past the whole backlog — so they are never used as the floor.
|
|
233
|
+
- **Which channels:** every channel with a cursor (at the time of the outage or since), plus
|
|
234
|
+
`SLACK_AGENT_CHANNEL` — so a first deploy, or a channel quiet before the outage, is not
|
|
235
|
+
invisible.
|
|
236
|
+
- **`channels[].truncated: true`** means the page cap (5 × 200 per channel) was hit with more
|
|
237
|
+
still waiting. `conversations.history` returns newest-first, so what's missing is the
|
|
238
|
+
**start** of the outage. The report's `note` also says `INCOMPLETE` — treat it as such.
|
|
239
|
+
|
|
240
|
+
**It reports and proposes — nothing auto-fires.** Acting on an item is always an explicit second
|
|
241
|
+
call: `POST /api/schedules/{id}/run` (409 + `reason` if it can't start). Same data is exposed as
|
|
242
|
+
the MCP `get_downtime` tool when `MCP_ALL_TOOLS=true`.
|
|
243
|
+
|
|
140
244
|
## Emitting events (to fire event-triggered schedules)
|
|
141
245
|
|
|
142
246
|
Any caller that can present shraga auth can push an event onto the bus:
|
|
@@ -123,7 +123,18 @@ Schedules sync across all envs but only **execute** where `DATA_SYNC_SCHEDULER_A
|
|
|
123
123
|
Two layers cause you to run — know both, and which to reach for:
|
|
124
124
|
|
|
125
125
|
**Schedules (`schedules.json`)** — a schedule is `trigger` + `task` (`prompt`/`bash`/`job`). One execution path, two trigger families:
|
|
126
|
-
- **Time**: `cron` / `interval` / `once`.
|
|
126
|
+
- **Time**: `cron` / `interval` / `once`. A window missed while you were down — or while the host
|
|
127
|
+
was **suspended** and you were merely frozen — obeys `onMissed`
|
|
128
|
+
(`run` default / `skip` / `offer`) plus a hard 6h staleness ceiling that overrides *every*
|
|
129
|
+
policy — so an 08:00 job never silently replays at 22:00. Anything not replayed is recorded as
|
|
130
|
+
`missedRun` on the schedule (visible in `GET /api/schedules`) for an on-demand run. See the
|
|
131
|
+
**scheduler** skill.
|
|
132
|
+
- **Asked "what did I miss?"** (after a power cut, or a laptop **suspend** — a slept host is
|
|
133
|
+
caught too, by a late heartbeat tick, `cause: 'suspend'`): `GET /api/downtime`. It
|
|
134
|
+
joins the recorded outage ranges to those `missedRun` windows and to the Slack messages that
|
|
135
|
+
arrived while you were down, and it **reports and proposes only** — run a missed window with
|
|
136
|
+
an explicit `POST /api/schedules/{id}/run`. Never replay everything you missed; a 14h-late
|
|
137
|
+
08:00 job is the incident this exists to prevent. Details: the **scheduler** skill.
|
|
127
138
|
- **Event** (`{ kind:'event', source, match? }`) — fires when a matching event hits the event bus. `match` is an AND-filter of payload dot-paths → values. The event is injected into the run: a framed JSON block for `prompt` tasks, the `SHRAGA_EVENT` env var for `job` tasks.
|
|
128
139
|
- Events arrive via `POST /api/events/:source` (auth-gated) or `ctx.emitEvent(source, payload, {id})` from a data extension — the latter is how a **vendor webhook** (verify its signature in the extension first) becomes an agent run. Bus + dispatcher: `src/server/events/`; fire path: `scheduler/engine.ts` `fireEvent()`. Full how-to (create / match / emit): the **scheduler** skill.
|
|
129
140
|
- **Built-in lifecycle source**: the system auto-emits `schedule.finished` (`{ scheduleId, name, status, sessionId, sessionUrl?, error? }`) when a time/manual run completes — react to your own runs (e.g. `match: { status: "error" }` → notify). Event-triggered runs don't emit it (loop guard). More internal sources can be added with one `emitEvent()` at the milestone.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.33",
|
|
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/boot.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { registerModuleRoutes, reconcileInstalledModules } from './modules/index
|
|
|
41
41
|
import { hydrateSlackUserToken } from './slack/oauth.ts';
|
|
42
42
|
import { registerMcpOAuthRoutes } from './mcp-oauth.ts';
|
|
43
43
|
import { registerEventRoutes } from './events/routes.ts';
|
|
44
|
+
import { startHeartbeat, recordBootGap, buildReport } from './downtime.ts';
|
|
44
45
|
import { registerWebhook } from './events/webhook.ts';
|
|
45
46
|
import { startEventDispatcher } from './events/dispatcher.ts';
|
|
46
47
|
import { seedOperators } from './contacts.ts';
|
|
@@ -390,6 +391,20 @@ function scheduleIfVisible(id: string, uid: string, isOwner = false): Schedule |
|
|
|
390
391
|
return undefined;
|
|
391
392
|
}
|
|
392
393
|
|
|
394
|
+
// "What did I miss?" — the on-demand downtime report. Read-only and INERT: it returns outage
|
|
395
|
+
// ranges, the windows phase 1 deliberately did not replay (`missedRun`), and the Slack messages
|
|
396
|
+
// that arrived while we were down, each with a PROPOSAL. Running any of it is a separate,
|
|
397
|
+
// explicit call (POST /api/schedules/:id/run). `?slack=0` skips the Slack read.
|
|
398
|
+
app.get('/api/downtime', requireAuth, async (req, res) => {
|
|
399
|
+
try {
|
|
400
|
+
const slack = String(req.query.slack ?? '1') !== '0';
|
|
401
|
+
res.json(await buildReport({ slack }));
|
|
402
|
+
} catch (err) {
|
|
403
|
+
console.error('[downtime] report failed:', err);
|
|
404
|
+
res.status(500).json({ error: (err as Error).message });
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
393
408
|
app.get('/api/schedules', requireAuth, (req, res) => {
|
|
394
409
|
const user = (req as any).user;
|
|
395
410
|
const schedules = scheduler.listSchedules().filter((s) => user.isOwner || s.scope === 'system' || s.createdBy.uid === user.uid);
|
|
@@ -414,6 +429,7 @@ app.post('/api/schedules', requireAuth, (req, res) => {
|
|
|
414
429
|
enabled: body.enabled ?? true,
|
|
415
430
|
trigger: body.trigger as Schedule['trigger'],
|
|
416
431
|
task: body.task as Schedule['task'],
|
|
432
|
+
onMissed: body.onMissed,
|
|
417
433
|
scope: 'user',
|
|
418
434
|
createdBy: { uid: user.uid, email: user.email },
|
|
419
435
|
createdAt: now,
|
|
@@ -438,6 +454,7 @@ app.put('/api/schedules/:id', requireAuth, (req, res) => {
|
|
|
438
454
|
enabled: body.enabled ?? existing.enabled,
|
|
439
455
|
trigger: (body.trigger ?? existing.trigger) as Schedule['trigger'],
|
|
440
456
|
task: (body.task ?? existing.task) as Schedule['task'],
|
|
457
|
+
onMissed: body.onMissed ?? existing.onMissed,
|
|
441
458
|
};
|
|
442
459
|
const result = scheduler.upsertSchedule(updated);
|
|
443
460
|
if (!result.ok) return res.status(400).json({ error: result.error });
|
|
@@ -469,9 +486,15 @@ app.post('/api/schedules/:id/run', requireAuth, (req, res) => {
|
|
|
469
486
|
const id = String(req.params.id);
|
|
470
487
|
if (!scheduleIfVisible(id, user.uid, user.isOwner)) return res.status(404).json({ error: 'Not found' });
|
|
471
488
|
const override = typeof req.body?.override === 'string' ? req.body.override.trim() || undefined : undefined;
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
489
|
+
const outcome = scheduler.runNow(id, override);
|
|
490
|
+
// A refusal here is a CONFLICT with the schedule's current state (a run already holds the
|
|
491
|
+
// cross-restart lock, the period is already done), not a missing resource — 404 sent operators
|
|
492
|
+
// hunting for a schedule that plainly exists.
|
|
493
|
+
if (!outcome.ok) {
|
|
494
|
+
if (outcome.reason === 'unknown-schedule') return res.status(404).json({ error: 'Not found' });
|
|
495
|
+
return res.status(409).json({ error: outcome.message, reason: outcome.reason });
|
|
496
|
+
}
|
|
497
|
+
res.json({ sessionId: outcome.sessionId, queued: outcome.queued ?? false });
|
|
475
498
|
});
|
|
476
499
|
|
|
477
500
|
app.post('/api/schedules/:id/cancel', requireAuth, (req, res) => {
|
|
@@ -1042,6 +1065,13 @@ setBroadcaster(broadcast); // let session-bus push async events (e.g. an add-on'
|
|
|
1042
1065
|
ensureWorkspaceDir();
|
|
1043
1066
|
watchWorkspace((event) => broadcast({ type: 'workspace_change', ...event }));
|
|
1044
1067
|
if (!PASSIVE) {
|
|
1068
|
+
// BEFORE anything that could take time: the gap is measured as (now - last heartbeat), so it must
|
|
1069
|
+
// be read and re-stamped while "now" still means boot — a slow start would otherwise inflate the
|
|
1070
|
+
// gap, or (once startHeartbeat is running) erase it. Order vs the scheduler's catch-up doesn't
|
|
1071
|
+
// matter for the join: missedSchedules() reads the two files independently, at report time.
|
|
1072
|
+
// Passive twins never write here — single-active-writer, same rule as the scheduler.
|
|
1073
|
+
recordBootGap();
|
|
1074
|
+
startHeartbeat();
|
|
1045
1075
|
scheduler.start(broadcast);
|
|
1046
1076
|
startEventDispatcher();
|
|
1047
1077
|
// Modules reconcile MUST follow scheduler.start(): upsertSchedule mutates the engine's
|
|
@@ -1102,6 +1132,8 @@ async function activateConsumers() {
|
|
|
1102
1132
|
console.log('[server] ACTIVATING — starting consumers and background writers');
|
|
1103
1133
|
await dataSync.init();
|
|
1104
1134
|
syncVendorRepos().catch(err => console.warn('[vendor-sync] error:', (err as Error).message));
|
|
1135
|
+
recordBootGap();
|
|
1136
|
+
startHeartbeat();
|
|
1105
1137
|
scheduler.start(broadcast);
|
|
1106
1138
|
startEventDispatcher();
|
|
1107
1139
|
mountFeatures({ app, requireAuth, broadcast, passive: false });
|
|
@@ -1806,7 +1838,22 @@ async function recoverInterruptedSessions() {
|
|
|
1806
1838
|
});
|
|
1807
1839
|
const resumePrompt = 'Your previous response was cut off by a server restart. The partial response has been preserved above. Continue from where you left off, and avoid repeating any side-effects (e.g. messages already sent) that may have completed before the interruption.';
|
|
1808
1840
|
setRunStatus(s.sessionId, 'idle');
|
|
1809
|
-
scheduler.resumeRun(s.scheduleId!, s.sessionId, resumePrompt);
|
|
1841
|
+
const outcome = scheduler.resumeRun(s.scheduleId!, s.sessionId, resumePrompt);
|
|
1842
|
+
if (!outcome.ok) {
|
|
1843
|
+
// The refusal is now the EXPECTED outcome after a long outage (stale window / onMissed).
|
|
1844
|
+
// Close the session out exactly like the not-resumable branch above — otherwise
|
|
1845
|
+
// scheduleRunStatus stays 'running', the sidebar shows it busy forever, and it can never
|
|
1846
|
+
// self-heal because getRunningSessions() filters on runStatus, which is already idle.
|
|
1847
|
+
appendMessage(s.sessionId, {
|
|
1848
|
+
id: crypto.randomUUID(),
|
|
1849
|
+
role: 'assistant',
|
|
1850
|
+
blocks: [{ type: 'error', text: `Not resumed after the server restart — ${outcome.message} (${outcome.reason}).` }],
|
|
1851
|
+
});
|
|
1852
|
+
updateScheduledSessionStatus(s.sessionId, 'error');
|
|
1853
|
+
scheduler.clearRunningMarker(s.scheduleId!);
|
|
1854
|
+
console.log(`[recovery] scheduler session ${s.sessionId.slice(0, 8)} (${s.scheduleId}) — resume refused (${outcome.reason}): ${outcome.message}`);
|
|
1855
|
+
continue;
|
|
1856
|
+
}
|
|
1810
1857
|
console.log(`[recovery] resuming scheduler session ${s.sessionId.slice(0, 8)} (${s.scheduleId}) in-place`);
|
|
1811
1858
|
continue;
|
|
1812
1859
|
}
|