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.
@@ -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.31",
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",
@@ -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 sessionId = scheduler.runNow(id, override);
473
- if (!sessionId) return res.status(404).json({ error: 'Not found' });
474
- res.json({ sessionId });
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
  }