shraga 0.0.3 → 0.1.1
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/README.md +82 -23
- package/defaults/agents/summarizer.md +16 -0
- package/defaults/agents/trace-extractor.md +84 -0
- package/defaults/bin/claude +45 -0
- package/defaults/bin/claude-revive +17 -0
- package/defaults/extensions/README.md +70 -0
- package/defaults/extensions/selftest.ext.ts +43 -0
- package/defaults/extensions/stripe-webhook.ext.ts +58 -0
- package/defaults/gmail-triage-prompt.md +42 -0
- package/defaults/scripts/README +4 -0
- package/defaults/scripts/agent-once.ts +67 -0
- package/defaults/scripts/backfill-slack-usernames.ts +82 -0
- package/defaults/scripts/notifier-throttle.ts +44 -0
- package/defaults/scripts/summarize-conversations.ts +5 -0
- package/defaults/shraga.config.ts +29 -0
- package/defaults/skills/add-skill.md +14 -0
- package/defaults/skills/artifacts.md +116 -0
- package/defaults/skills/code-review.md +26 -0
- package/defaults/skills/communications.md +54 -0
- package/defaults/skills/context-audit.md +87 -0
- package/defaults/skills/debug.md +10 -0
- package/defaults/skills/garden.md +179 -0
- package/defaults/skills/github-contributor.md +35 -0
- package/defaults/skills/identity.md +30 -0
- package/defaults/skills/mcp-server.md +62 -0
- package/defaults/skills/mcps-sync.md +105 -0
- package/defaults/skills/plan.md +9 -0
- package/defaults/skills/platform.md +177 -0
- package/defaults/skills/reconcile.md +239 -0
- package/defaults/skills/scheduler.md +192 -0
- package/defaults/skills/self-aware.md +136 -0
- package/defaults/skills/shraga-know.md +333 -0
- package/defaults/skills/stripe.md +55 -0
- package/defaults/skills/write-tests.md +10 -0
- package/defaults/skills-defaults.json +1 -0
- package/defaults/system-prompt.md +46 -0
- package/defaults/workspace/context.md +28 -0
- package/defaults/workspace.md +50 -0
- package/defaults/zdotdir/.gitignore +8 -0
- package/defaults/zdotdir/.zlogin +3 -0
- package/defaults/zdotdir/.zprofile +1 -0
- package/defaults/zdotdir/.zshenv +4 -0
- package/defaults/zdotdir/.zshrc +3 -0
- package/dist/client/assets/index-BoHttkMt.js +1940 -0
- package/dist/client/assets/index-DdibEb2O.css +10 -0
- package/dist/client/index.html +22 -0
- package/package.json +59 -14
- package/src/cli.ts +71 -46
- package/src/client/App.tsx +510 -0
- package/src/client/components/ArtifactCard.tsx +26 -0
- package/src/client/components/ArtifactPanel.tsx +138 -0
- package/src/client/components/AuthedImage.tsx +85 -0
- package/src/client/components/AutocompleteTextarea.tsx +149 -0
- package/src/client/components/ChatView.tsx +866 -0
- package/src/client/components/CliAuthConsent.tsx +98 -0
- package/src/client/components/ConfigPanel.tsx +328 -0
- package/src/client/components/ConversationHeader.tsx +156 -0
- package/src/client/components/ConversationPane.tsx +277 -0
- package/src/client/components/LoginPage.tsx +81 -0
- package/src/client/components/MachineStats.tsx +77 -0
- package/src/client/components/McpManager.tsx +209 -0
- package/src/client/components/MessageInput.tsx +263 -0
- package/src/client/components/OAuthConsent.tsx +103 -0
- package/src/client/components/SchedulesManager.tsx +99 -0
- package/src/client/components/Sidebar.tsx +235 -0
- package/src/client/components/SkillsManager.tsx +280 -0
- package/src/client/components/SmartChart.tsx +167 -0
- package/src/client/components/Toast.tsx +54 -0
- package/src/client/components/WorkspaceTree.tsx +313 -0
- package/src/client/components/ZoomableImage.tsx +123 -0
- package/src/client/components/artifact-presets.ts +10 -0
- package/src/client/components/schedules/ScheduleEditor.tsx +264 -0
- package/src/client/components/schedules/ScheduleList.tsx +271 -0
- package/src/client/components/ui/accordion.tsx +50 -0
- package/src/client/components/ui/button.tsx +43 -0
- package/src/client/components/ui/dialog.tsx +82 -0
- package/src/client/components/ui/input.tsx +19 -0
- package/src/client/components/ui/scroll-area.tsx +39 -0
- package/src/client/components/ui/textarea.tsx +18 -0
- package/src/client/globals.css +51 -0
- package/src/client/hooks/useAgentSocket.ts +79 -0
- package/src/client/hooks/useArtifacts.ts +89 -0
- package/src/client/hooks/useAuth.ts +127 -0
- package/src/client/hooks/useConversation.ts +412 -0
- package/src/client/hooks/useDarkMode.ts +57 -0
- package/src/client/hooks/useIsMobile.ts +23 -0
- package/src/client/hooks/usePush.ts +127 -0
- package/src/client/hooks/useSchedules.ts +73 -0
- package/src/client/hooks/useUnread.ts +238 -0
- package/src/client/lib/desktopAttention.ts +75 -0
- package/src/client/lib/firebase.ts +32 -0
- package/src/client/lib/googleAuthNative.ts +94 -0
- package/src/client/lib/native.ts +43 -0
- package/src/client/lib/schedule-types.ts +34 -0
- package/src/client/lib/sessionApi.ts +58 -0
- package/src/client/lib/slots.tsx +79 -0
- package/src/client/lib/storage.ts +39 -0
- package/src/client/lib/utils.ts +26 -0
- package/src/client/lib/workspaceContext.tsx +54 -0
- package/src/client/lib/ws.ts +203 -0
- package/src/client/main.tsx +14 -0
- package/src/mcp-stdio-bridge.ts +70 -0
- package/src/scripts/summarize-conversations.ts +5 -0
- package/src/scripts/typecheck.ts +43 -0
- package/src/server/agents.ts +54 -0
- package/src/server/api-keys.ts +63 -0
- package/src/server/artifacts/artifacts.export.ts +85 -0
- package/src/server/artifacts/artifacts.handler.ts +93 -0
- package/src/server/artifacts/artifacts.routes.ts +43 -0
- package/src/server/artifacts/artifacts.service.ts +100 -0
- package/src/server/artifacts/artifacts.types.ts +31 -0
- package/src/server/auth.ts +262 -0
- package/src/server/claude.ts +394 -0
- package/src/server/commands.ts +21 -0
- package/src/server/contacts.ts +177 -0
- package/src/server/conversation-summarizer.ts +204 -0
- package/src/server/data-sync.ts +664 -0
- package/src/server/directives.ts +91 -0
- package/src/server/engine/claude-code.ts +514 -0
- package/src/server/engine/index.ts +41 -0
- package/src/server/engine/registry.ts +21 -0
- package/src/server/engine/shared.ts +47 -0
- package/src/server/engine/types.ts +48 -0
- package/src/server/env-resolve.ts +71 -0
- package/src/server/env-sanitize.ts +9 -0
- package/src/server/events/bus.ts +29 -0
- package/src/server/events/dispatcher.ts +48 -0
- package/src/server/events/routes.ts +19 -0
- package/src/server/events/types.ts +9 -0
- package/src/server/extensions.ts +101 -0
- package/src/server/features.ts +109 -0
- package/src/server/file-inject.ts +45 -0
- package/src/server/hooks.ts +142 -0
- package/src/server/idempotency.ts +25 -0
- package/src/server/index.ts +1715 -0
- package/src/server/integrity-audit.ts +132 -0
- package/src/server/mcp-catalog.ts +70 -0
- package/src/server/mcp-oauth.ts +198 -0
- package/src/server/mcp-progress.ts +45 -0
- package/src/server/mcp-server.ts +456 -0
- package/src/server/mcp-sidecar.ts +87 -0
- package/src/server/mcp.ts +291 -0
- package/src/server/model-aliases.ts +76 -0
- package/src/server/paths.ts +24 -0
- package/src/server/polls.ts +175 -0
- package/src/server/push/apns.ts +113 -0
- package/src/server/push/fcm.ts +108 -0
- package/src/server/push/push.ts +66 -0
- package/src/server/push/store.ts +84 -0
- package/src/server/push/triggers.ts +99 -0
- package/src/server/scheduler/builtins.ts +157 -0
- package/src/server/scheduler/engine.ts +432 -0
- package/src/server/scheduler/index.ts +4 -0
- package/src/server/scheduler/runner.ts +334 -0
- package/src/server/scheduler/storage.ts +98 -0
- package/src/server/scheduler/timing.ts +70 -0
- package/src/server/scheduler/types.ts +62 -0
- package/src/server/sdk-utils.ts +45 -0
- package/src/server/seed.ts +174 -0
- package/src/server/session-bus.ts +18 -0
- package/src/server/sessions.ts +559 -0
- package/src/server/shraga-config.ts +167 -0
- package/src/server/skills.ts +372 -0
- package/src/server/slack/api.ts +37 -0
- package/src/server/slack/bot.ts +391 -0
- package/src/server/slack/context-cache.ts +42 -0
- package/src/server/slack/feature.ts +59 -0
- package/src/server/slack/mention-rewrite.ts +59 -0
- package/src/server/slack/oauth.ts +102 -0
- package/src/server/slack/questions.ts +112 -0
- package/src/server/slack/sessions.ts +139 -0
- package/src/server/stats.ts +106 -0
- package/src/server/summarize.ts +11 -0
- package/src/server/turn-context.ts +61 -0
- package/src/server/unclaw-config.ts +19 -0
- package/src/server/unread.ts +79 -0
- package/src/server/user-context.ts +33 -0
- package/src/server/vendor-sync.ts +52 -0
- package/src/server/voice-provider.ts +74 -0
- package/src/server/workspace.ts +249 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { loadSchedules, saveSchedules, readCompletionMarker, writeCompletionMarker, readRunningMarker, isProcessAlive, loadThrottleState, saveThrottleState } from './storage.ts';
|
|
2
|
+
import { computeNextRun, computePrevRun, validateTrigger } from './timing.ts';
|
|
3
|
+
import { runSchedule, type ResumeOptions, type EventContext } from './runner.ts';
|
|
4
|
+
import { backfillScope, ensureBuiltinSchedules } from './builtins.ts';
|
|
5
|
+
import { emitEvent } from '../events/bus.ts';
|
|
6
|
+
import type { Schedule } from './types.ts';
|
|
7
|
+
|
|
8
|
+
type Broadcast = (data: object) => void;
|
|
9
|
+
|
|
10
|
+
/** A pending fire awaiting its turn in a schedule's serial queue. */
|
|
11
|
+
interface QueuedFire {
|
|
12
|
+
firedAt: number;
|
|
13
|
+
override?: string;
|
|
14
|
+
eventCtx?: EventContext;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface RuntimeState {
|
|
18
|
+
schedules: Schedule[];
|
|
19
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
20
|
+
/** Per-schedule queue of pending fires (FIFO, capped). */
|
|
21
|
+
queues: Map<string, QueuedFire[]>;
|
|
22
|
+
/** Schedules currently running (id → AbortController for the live run). */
|
|
23
|
+
running: Map<string, AbortController>;
|
|
24
|
+
broadcast: Broadcast;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const QUEUE_CAP = 5;
|
|
28
|
+
/** Max setTimeout delay (2^31-1 ms ≈ 24.8 days); longer delays overflow and fire immediately. */
|
|
29
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
30
|
+
/** Only the designated instance fires schedules (DATA_SYNC_SCHEDULER_ACTIVE=true).
|
|
31
|
+
* Inactive instances still load/serve/edit schedules — they just never fire them.
|
|
32
|
+
* Never flip persisted `enabled` flags here: a later save (toggle/upsert/run) would
|
|
33
|
+
* cement the disablement into schedules.json and data-sync would spread it. */
|
|
34
|
+
let schedulerActive = false;
|
|
35
|
+
const state: RuntimeState = {
|
|
36
|
+
schedules: [],
|
|
37
|
+
timer: null,
|
|
38
|
+
queues: new Map(),
|
|
39
|
+
running: new Map(),
|
|
40
|
+
broadcast: () => {},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export function start(broadcast: Broadcast): void {
|
|
46
|
+
state.broadcast = broadcast;
|
|
47
|
+
const loaded = loadSchedules();
|
|
48
|
+
backfillScope(loaded);
|
|
49
|
+
state.schedules = ensureBuiltinSchedules(loaded);
|
|
50
|
+
schedulerActive = process.env.DATA_SYNC_SCHEDULER_ACTIVE === 'true';
|
|
51
|
+
if (!schedulerActive) {
|
|
52
|
+
console.log(`[scheduler] inactive (DATA_SYNC_SCHEDULER_ACTIVE not set) — ${state.schedules.length} schedule(s) loaded but will not fire on this instance`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// Initialize nextRun for enabled schedules, skip stale `once` triggers
|
|
56
|
+
for (const s of state.schedules) {
|
|
57
|
+
// Force-idle any schedule that claims to be "running" — after restart nothing is actually running
|
|
58
|
+
if (s.lastRun?.status === 'running') {
|
|
59
|
+
console.log(`[scheduler] clearing stale 'running' status for ${s.id}`);
|
|
60
|
+
s.lastRun.status = 'error';
|
|
61
|
+
s.lastRun.error = 'interrupted by server restart';
|
|
62
|
+
}
|
|
63
|
+
if (!s.enabled) { s.nextRun = undefined; continue; }
|
|
64
|
+
const next = computeNextRun(s.trigger);
|
|
65
|
+
if (next === null) {
|
|
66
|
+
if (s.trigger.kind === 'once') s.enabled = false;
|
|
67
|
+
s.nextRun = undefined;
|
|
68
|
+
} else {
|
|
69
|
+
s.nextRun = next;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Catch up missed cron fires (e.g. process was down when cron should have fired)
|
|
73
|
+
const catchUps: string[] = [];
|
|
74
|
+
for (const s of state.schedules) {
|
|
75
|
+
if (!s.enabled || s.trigger.kind !== 'cron') continue;
|
|
76
|
+
const prev = computePrevRun(s.trigger);
|
|
77
|
+
if (prev === null) continue;
|
|
78
|
+
const lastAt = s.lastRun?.at;
|
|
79
|
+
if (lastAt === undefined) continue; // never ran — nothing to catch up
|
|
80
|
+
if (lastAt < prev) {
|
|
81
|
+
const marker = readCompletionMarker(s.id);
|
|
82
|
+
if (marker && marker.completedAt >= prev) {
|
|
83
|
+
console.log(`[scheduler] skipping catch-up for ${s.id} — already completed at ${new Date(marker.completedAt).toISOString()} by ${marker.triggeredBy}`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const running = readRunningMarker(s.id);
|
|
87
|
+
if (running && isProcessAlive(running.pid)) {
|
|
88
|
+
console.log(`[scheduler] skipping catch-up for ${s.id} — still running (pid ${running.pid}, started ${new Date(running.startedAt).toISOString()})`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
catchUps.push(s.id);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (catchUps.length) {
|
|
95
|
+
console.log(`[scheduler] catch-up: ${catchUps.join(', ')} (delayed 10s for MCP init)`);
|
|
96
|
+
setTimeout(() => {
|
|
97
|
+
for (const id of catchUps) {
|
|
98
|
+
const s = getSchedule(id);
|
|
99
|
+
if (s?.enabled) {
|
|
100
|
+
console.log(`[scheduler] catch-up: firing ${id}`);
|
|
101
|
+
runNow(id);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}, 10_000);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
saveSchedules(state.schedules);
|
|
108
|
+
replan();
|
|
109
|
+
console.log(`[scheduler] started with ${state.schedules.length} schedule(s)`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function listSchedules(): Schedule[] {
|
|
113
|
+
return [...state.schedules].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function getRunningIds(): string[] {
|
|
117
|
+
return [...state.running.keys()];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function getSchedule(id: string): Schedule | undefined {
|
|
121
|
+
return state.schedules.find((s) => s.id === id);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function upsertSchedule(s: Schedule): { ok: true; schedule: Schedule } | { ok: false; error: string } {
|
|
125
|
+
const err = validateTrigger(s.trigger);
|
|
126
|
+
if (err) return { ok: false, error: err };
|
|
127
|
+
|
|
128
|
+
s.updatedAt = Date.now();
|
|
129
|
+
if (s.enabled) {
|
|
130
|
+
const next = computeNextRun(s.trigger);
|
|
131
|
+
if (next === null && s.trigger.kind === 'once') s.enabled = false;
|
|
132
|
+
s.nextRun = next ?? undefined;
|
|
133
|
+
} else {
|
|
134
|
+
s.nextRun = undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const idx = state.schedules.findIndex((x) => x.id === s.id);
|
|
138
|
+
if (idx >= 0) state.schedules[idx] = s;
|
|
139
|
+
else state.schedules.push(s);
|
|
140
|
+
|
|
141
|
+
saveSchedules(state.schedules);
|
|
142
|
+
replan();
|
|
143
|
+
state.broadcast({ type: 'schedule:updated', schedule: s });
|
|
144
|
+
return { ok: true, schedule: s };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function deleteSchedule(id: string): boolean {
|
|
148
|
+
const idx = state.schedules.findIndex((s) => s.id === id);
|
|
149
|
+
if (idx < 0) return false;
|
|
150
|
+
state.schedules.splice(idx, 1);
|
|
151
|
+
state.queues.delete(id);
|
|
152
|
+
const ac = state.running.get(id);
|
|
153
|
+
if (ac) ac.abort();
|
|
154
|
+
saveSchedules(state.schedules);
|
|
155
|
+
replan();
|
|
156
|
+
state.broadcast({ type: 'schedule:deleted', id });
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function toggleSchedule(id: string, enabled: boolean): Schedule | null {
|
|
161
|
+
const s = getSchedule(id);
|
|
162
|
+
if (!s) return null;
|
|
163
|
+
s.enabled = enabled;
|
|
164
|
+
s.updatedAt = Date.now();
|
|
165
|
+
if (enabled) {
|
|
166
|
+
const next = computeNextRun(s.trigger);
|
|
167
|
+
if (next === null && s.trigger.kind === 'once') s.enabled = false;
|
|
168
|
+
s.nextRun = next ?? undefined;
|
|
169
|
+
// Stamp lastRun.at so catch-up doesn't treat past cron windows as missed
|
|
170
|
+
if (s.trigger.kind === 'cron') {
|
|
171
|
+
s.lastRun = { at: Date.now(), sessionId: s.lastRun?.sessionId ?? '', status: s.lastRun?.status ?? 'ok' };
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
s.nextRun = undefined;
|
|
175
|
+
}
|
|
176
|
+
saveSchedules(state.schedules);
|
|
177
|
+
replan();
|
|
178
|
+
state.broadcast({ type: 'schedule:updated', schedule: s });
|
|
179
|
+
return s;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function runNow(id: string, override?: string): string | null {
|
|
183
|
+
const s = getSchedule(id);
|
|
184
|
+
if (!s) return null;
|
|
185
|
+
return enqueueFire(s, Date.now(), override, true);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Fire all enabled `event`-trigger schedules whose `source` matches and whose
|
|
190
|
+
* optional `match` filter fits the payload. Returns the ids that fired.
|
|
191
|
+
* Gated by `schedulerActive` so only the designated instance executes (mirrors the
|
|
192
|
+
* timer path) — prevents blue-green double-firing. Unlike `runNow`, never manual.
|
|
193
|
+
*/
|
|
194
|
+
export function fireEvent(source: string, payload: unknown): string[] {
|
|
195
|
+
if (!schedulerActive) return [];
|
|
196
|
+
const fired: string[] = [];
|
|
197
|
+
for (const s of state.schedules) {
|
|
198
|
+
if (!s.enabled || s.trigger.kind !== 'event') continue;
|
|
199
|
+
if (s.trigger.source !== source) continue;
|
|
200
|
+
if (!matchesEvent(s.trigger.match, payload)) continue;
|
|
201
|
+
if (!throttleAllows(s, payload)) {
|
|
202
|
+
console.log(`[scheduler] throttled event fire for ${s.id} (duplicate within window)`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
enqueueFire(s, Date.now(), undefined, false, { source, payload });
|
|
206
|
+
fired.push(s.id);
|
|
207
|
+
}
|
|
208
|
+
return fired;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Normalize a payload value for throttle keys: lowercase, digits→`#`, collapse
|
|
212
|
+
* whitespace, cap length — so values differing only by timestamps/ids collapse. */
|
|
213
|
+
function normalizeThrottleField(v: string): string {
|
|
214
|
+
return v.toLowerCase().replace(/\d+/g, '#').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Returns false if this event fire is a duplicate within the trigger's throttle window
|
|
218
|
+
* (and should be suppressed). Records the fire timestamp when it's allowed. Triggers
|
|
219
|
+
* without `throttle` always pass. State is persisted + self-pruned in trigger-throttle.json. */
|
|
220
|
+
function throttleAllows(s: Schedule, payload: unknown): boolean {
|
|
221
|
+
const throttle = s.trigger.kind === 'event' ? s.trigger.throttle : undefined;
|
|
222
|
+
if (!throttle || throttle.windowSec <= 0) return true;
|
|
223
|
+
const flat = flattenPayload(payload);
|
|
224
|
+
const sig = (throttle.byFields ?? []).map((f) => normalizeThrottleField(flat[f] ?? '')).join('|');
|
|
225
|
+
const key = `${s.id}::${sig}`;
|
|
226
|
+
const windowMs = throttle.windowSec * 1000;
|
|
227
|
+
const now = Date.now();
|
|
228
|
+
|
|
229
|
+
const state = loadThrottleState();
|
|
230
|
+
for (const k of Object.keys(state)) if (now - state[k] > windowMs) delete state[k]; // self-prune
|
|
231
|
+
const last = state[key];
|
|
232
|
+
const suppress = last !== undefined && now - last < windowMs;
|
|
233
|
+
if (!suppress) state[key] = now;
|
|
234
|
+
saveThrottleState(state); // persist record or prune
|
|
235
|
+
return !suppress;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Shallow match: every `key=value` in `match` must equal the flattened payload's
|
|
239
|
+
* dot-path value (case-insensitive). Empty/absent match → fires on any payload. */
|
|
240
|
+
function matchesEvent(match: Record<string, string> | undefined, payload: unknown): boolean {
|
|
241
|
+
if (!match || Object.keys(match).length === 0) return true;
|
|
242
|
+
const flat = flattenPayload(payload);
|
|
243
|
+
return Object.entries(match).every(([k, v]) => {
|
|
244
|
+
const actual = flat[k];
|
|
245
|
+
return actual !== undefined && actual.toLowerCase() === String(v).toLowerCase();
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function flattenPayload(obj: unknown, prefix = '', out: Record<string, string> = {}): Record<string, string> {
|
|
250
|
+
if (obj === null || obj === undefined) return out;
|
|
251
|
+
if (typeof obj !== 'object') { if (prefix) out[prefix] = String(obj); return out; }
|
|
252
|
+
if (Array.isArray(obj)) {
|
|
253
|
+
obj.forEach((v, i) => flattenPayload(v, prefix ? `${prefix}.${i}` : String(i), out));
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
|
257
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
258
|
+
if (v !== null && typeof v === 'object') flattenPayload(v, key, out);
|
|
259
|
+
else out[key] = String(v);
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function cancelRun(id: string): boolean {
|
|
265
|
+
const ac = state.running.get(id);
|
|
266
|
+
if (!ac) return false;
|
|
267
|
+
ac.abort();
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Resume an interrupted scheduler run in-place on its existing session, instead of
|
|
273
|
+
* letting startup catch-up spawn a brand-new conversation (which produced duplicate
|
|
274
|
+
* side-effects, e.g. a second Slack post). Mirrors the web/slack restart-resume path.
|
|
275
|
+
* No-op if the schedule is unknown or already running.
|
|
276
|
+
*/
|
|
277
|
+
export function resumeRun(scheduleId: string, sessionId: string, prompt: string): string | null {
|
|
278
|
+
const s = getSchedule(scheduleId);
|
|
279
|
+
if (!s) {
|
|
280
|
+
console.warn(`[scheduler] resumeRun: unknown schedule ${scheduleId}`);
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
if (state.running.has(scheduleId)) {
|
|
284
|
+
console.log(`[scheduler] resumeRun: ${scheduleId} already running — skipping resume`);
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
console.log(`[scheduler] resuming ${scheduleId} in-place on session ${sessionId.slice(0, 30)}…`);
|
|
288
|
+
return startRun(s, Date.now(), undefined, { sessionId, prompt });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Internals ───────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
function replan(): void {
|
|
294
|
+
if (!schedulerActive) return;
|
|
295
|
+
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
|
296
|
+
|
|
297
|
+
const now = Date.now();
|
|
298
|
+
let soonest: { s: Schedule; at: number } | null = null;
|
|
299
|
+
for (const s of state.schedules) {
|
|
300
|
+
if (!s.enabled || s.nextRun === undefined) continue;
|
|
301
|
+
if (!soonest || s.nextRun < soonest.at) soonest = { s, at: s.nextRun };
|
|
302
|
+
}
|
|
303
|
+
if (!soonest) return;
|
|
304
|
+
|
|
305
|
+
// setTimeout overflows past 2^31-1 ms (~24.8 days) → fires immediately. Cap the
|
|
306
|
+
// delay; for a far-future fire (monthly/yearly cron) the timer wakes early,
|
|
307
|
+
// fireDue() finds nothing due (nextRun still ahead) and re-arms via its own replan().
|
|
308
|
+
const delay = Math.min(Math.max(0, soonest.at - now), MAX_TIMER_MS);
|
|
309
|
+
state.timer = setTimeout(() => {
|
|
310
|
+
state.timer = null;
|
|
311
|
+
fireDue();
|
|
312
|
+
}, delay);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function fireDue(): void {
|
|
316
|
+
const now = Date.now();
|
|
317
|
+
for (const s of state.schedules) {
|
|
318
|
+
if (!s.enabled || s.nextRun === undefined) continue;
|
|
319
|
+
if (s.nextRun <= now) enqueueFire(s, s.nextRun);
|
|
320
|
+
}
|
|
321
|
+
// Advance nextRun for recurring triggers; disable fired `once` triggers
|
|
322
|
+
for (const s of state.schedules) {
|
|
323
|
+
if (!s.enabled) continue;
|
|
324
|
+
if (s.nextRun !== undefined && s.nextRun <= now) {
|
|
325
|
+
if (s.trigger.kind === 'once') {
|
|
326
|
+
s.enabled = false;
|
|
327
|
+
s.nextRun = undefined;
|
|
328
|
+
} else {
|
|
329
|
+
const next = computeNextRun(s.trigger, now);
|
|
330
|
+
s.nextRun = next ?? undefined;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
saveSchedules(state.schedules);
|
|
335
|
+
replan();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function enqueueFire(s: Schedule, firedAt: number, override?: string, manual = false, eventCtx?: EventContext): string | null {
|
|
339
|
+
// Skip if this cron period was already completed or still running from a prior server instance.
|
|
340
|
+
// Manual runs (runNow from UI/API) always proceed.
|
|
341
|
+
if (!manual && s.trigger.kind === 'cron') {
|
|
342
|
+
const prev = computePrevRun(s.trigger, firedAt + 1);
|
|
343
|
+
if (prev !== null) {
|
|
344
|
+
const marker = readCompletionMarker(s.id);
|
|
345
|
+
if (marker && marker.completedAt >= prev) {
|
|
346
|
+
console.log(`[scheduler] skipping fire for ${s.id} — already completed this period (at ${new Date(marker.completedAt).toISOString()})`);
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
const running = readRunningMarker(s.id);
|
|
350
|
+
if (running && isProcessAlive(running.pid)) {
|
|
351
|
+
console.log(`[scheduler] skipping fire for ${s.id} — still running (pid ${running.pid})`);
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const q = state.queues.get(s.id) ?? [];
|
|
358
|
+
|
|
359
|
+
// If not currently running, start immediately
|
|
360
|
+
if (!state.running.has(s.id)) {
|
|
361
|
+
return startRun(s, firedAt, override, undefined, eventCtx);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Running — enqueue, dropping oldest if over cap (prefer freshest)
|
|
365
|
+
q.push({ firedAt, override, eventCtx });
|
|
366
|
+
while (q.length > QUEUE_CAP) {
|
|
367
|
+
const dropped = q.shift();
|
|
368
|
+
console.warn(`[scheduler] queue overflow for ${s.id} (cap=${QUEUE_CAP}), dropped fire @ ${dropped?.firedAt}`);
|
|
369
|
+
}
|
|
370
|
+
state.queues.set(s.id, q);
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function startRun(s: Schedule, _firedAt: number, override?: string, resume?: ResumeOptions, eventCtx?: EventContext): string | null {
|
|
375
|
+
// Deep-copy task so edits mid-run don't affect the in-flight execution
|
|
376
|
+
const snapshot: Schedule = JSON.parse(JSON.stringify(s));
|
|
377
|
+
let sessionId: string | null = null;
|
|
378
|
+
const onEvent = (ev: object) => state.broadcast(ev);
|
|
379
|
+
const register = (sid: string, ac: AbortController) => {
|
|
380
|
+
sessionId = sid;
|
|
381
|
+
state.running.set(s.id, ac);
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
state.broadcast({ type: 'schedule:fired', scheduleId: s.id });
|
|
385
|
+
|
|
386
|
+
runSchedule(snapshot, onEvent, register, override, resume, eventCtx)
|
|
387
|
+
.then((summary) => {
|
|
388
|
+
const live = getSchedule(s.id);
|
|
389
|
+
if (live) {
|
|
390
|
+
live.lastRun = summary;
|
|
391
|
+
live.runCount = (live.runCount ?? 0) + 1;
|
|
392
|
+
saveSchedules(state.schedules);
|
|
393
|
+
state.broadcast({ type: 'schedule:updated', schedule: live });
|
|
394
|
+
}
|
|
395
|
+
if (summary.status === 'ok') {
|
|
396
|
+
writeCompletionMarker({ completedAt: summary.at, triggeredBy: 'scheduler', scheduleId: s.id });
|
|
397
|
+
if (live && live.trigger.kind === 'once') {
|
|
398
|
+
console.log(`[scheduler] auto-deleting completed once-schedule ${s.id}`);
|
|
399
|
+
deleteSchedule(s.id);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// Lifecycle event: announce completion on the bus so other automations can react
|
|
403
|
+
// (e.g. "after the nightly reconcile → run smoke tests", "any schedule errors → notify").
|
|
404
|
+
// Suppressed for event-triggered runs — otherwise an event-fired run would emit a
|
|
405
|
+
// `schedule.finished` that could re-fire the same listener, looping. (CC-hooks-style
|
|
406
|
+
// lifecycle source; see the scheduler skill.)
|
|
407
|
+
if (!eventCtx) {
|
|
408
|
+
emitEvent('schedule.finished', {
|
|
409
|
+
scheduleId: s.id,
|
|
410
|
+
name: s.name,
|
|
411
|
+
status: summary.status,
|
|
412
|
+
sessionId: summary.sessionId,
|
|
413
|
+
error: summary.error,
|
|
414
|
+
}, { id: summary.sessionId });
|
|
415
|
+
}
|
|
416
|
+
})
|
|
417
|
+
.catch((err) => {
|
|
418
|
+
console.error(`[scheduler] unexpected run failure for ${s.id}:`, err);
|
|
419
|
+
})
|
|
420
|
+
.finally(() => {
|
|
421
|
+
state.running.delete(s.id);
|
|
422
|
+
const q = state.queues.get(s.id);
|
|
423
|
+
if (q && q.length > 0) {
|
|
424
|
+
const next = q.shift()!;
|
|
425
|
+
state.queues.set(s.id, q);
|
|
426
|
+
const live = getSchedule(s.id);
|
|
427
|
+
if (live) startRun(live, next.firedAt, next.override, undefined, next.eventCtx);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
return sessionId;
|
|
432
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { start, listSchedules, getRunningIds, getSchedule, upsertSchedule, deleteSchedule, toggleSchedule, runNow, fireEvent, cancelRun, resumeRun } from './engine.ts';
|
|
2
|
+
export { isSystemSchedule } from './builtins.ts';
|
|
3
|
+
export { writeCompletionMarker, clearRunningMarker } from './storage.ts';
|
|
4
|
+
export type { Schedule, Trigger, Task, Scope, ScheduleRunSummary, CompletionMarker } from './types.ts';
|