@swarmclawai/swarmclaw 1.9.8 → 1.9.9

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.
@@ -13,6 +13,7 @@ import { ensureAgentThreadSession } from '@/lib/server/agents/agent-thread-sessi
13
13
  import { hasActiveProtocolRunForSchedule, launchProtocolRunForSchedule } from '@/lib/server/protocols/protocol-service'
14
14
  import { hmrSingleton } from '@/lib/shared-utils'
15
15
  import { log } from '@/lib/server/logger'
16
+ import { appendScheduleHistoryEntry } from '@/lib/server/schedules/schedule-history'
16
17
  import type { Schedule } from '@/types'
17
18
 
18
19
  const TAG = 'scheduler'
@@ -131,7 +132,13 @@ async function tick(now = Date.now()) {
131
132
  const scheduleSignature = getScheduleSignatureKey(schedule)
132
133
  if (scheduleSignature && inFlightScheduleKeys.has(scheduleSignature)) {
133
134
  advanceSchedule(schedule)
134
- upsertSchedule(schedule.id, schedule)
135
+ upsertSchedule(schedule.id, appendScheduleHistoryEntry(schedule, {
136
+ now,
137
+ actor: 'system',
138
+ action: 'skipped',
139
+ summary: `Schedule skipped because a run is already in flight: "${schedule.name}"`,
140
+ metadata: { reason: 'in_flight' },
141
+ }))
135
142
  continue
136
143
  }
137
144
 
@@ -139,7 +146,19 @@ async function tick(now = Date.now()) {
139
146
  if (!agent) {
140
147
  log.error(TAG, `Agent ${schedule.agentId} not found for schedule ${schedule.id}`)
141
148
  schedule.status = 'failed'
142
- upsertSchedule(schedule.id, schedule)
149
+ upsertSchedule(schedule.id, appendScheduleHistoryEntry(schedule, {
150
+ now,
151
+ actor: 'system',
152
+ action: 'failed',
153
+ summary: `Schedule failed because agent was not found: "${schedule.name}"`,
154
+ changes: [{
155
+ field: 'status',
156
+ label: 'Status',
157
+ before: 'active',
158
+ after: 'failed',
159
+ }],
160
+ metadata: { reason: 'agent_not_found' },
161
+ }))
143
162
  pushMainLoopEventToMainSessions({
144
163
  type: 'schedule_failed',
145
164
  text: `Schedule failed: "${schedule.name}" (${schedule.id}) — agent ${schedule.agentId} not found.`,
@@ -149,7 +168,13 @@ async function tick(now = Date.now()) {
149
168
  if (isAgentDisabled(agent)) {
150
169
  log.warn(TAG, `Skipping schedule "${schedule.name}" (${schedule.id}) because agent ${schedule.agentId} is disabled`)
151
170
  advanceSchedule(schedule)
152
- upsertSchedule(schedule.id, schedule)
171
+ upsertSchedule(schedule.id, appendScheduleHistoryEntry(schedule, {
172
+ now,
173
+ actor: 'system',
174
+ action: 'skipped',
175
+ summary: `Schedule skipped because agent is disabled: "${schedule.name}"`,
176
+ metadata: { reason: 'agent_disabled' },
177
+ }))
153
178
  pushMainLoopEventToMainSessions({
154
179
  type: 'schedule_skipped',
155
180
  text: `Schedule skipped: "${schedule.name}" (${schedule.id}) — agent ${schedule.agentId} is disabled.`,
@@ -162,53 +187,60 @@ async function tick(now = Date.now()) {
162
187
  schedule.runNumber = (schedule.runNumber || 0) + 1
163
188
  // Compute next run
164
189
  advanceSchedule(schedule)
190
+ const firedSchedule = appendScheduleHistoryEntry(schedule, {
191
+ now,
192
+ actor: 'system',
193
+ action: 'run_started',
194
+ summary: `Schedule run started: "${schedule.name}"`,
195
+ metadata: { runNumber: schedule.runNumber || 0 },
196
+ })
165
197
 
166
- if (shouldWakeScheduleSession(schedule)) {
198
+ if (shouldWakeScheduleSession(firedSchedule)) {
167
199
  // Wake-only: no board task, just heartbeat the agent
168
- upsertSchedule(schedule.id, schedule)
169
- const wakeSessionId = resolveScheduleWakeSessionId(schedule, agents as Record<string, unknown>)
200
+ upsertSchedule(firedSchedule.id, firedSchedule)
201
+ const wakeSessionId = resolveScheduleWakeSessionId(firedSchedule, agents as Record<string, unknown>)
170
202
 
171
- const wakeMessage = schedule.message || `Schedule triggered: ${schedule.name}`
203
+ const wakeMessage = firedSchedule.message || `Schedule triggered: ${firedSchedule.name}`
172
204
  pushMainLoopEventToMainSessions({
173
205
  type: 'schedule_fired',
174
- text: `Schedule fired (wake-only): "${schedule.name}" (${schedule.id}) run #${schedule.runNumber}`,
206
+ text: `Schedule fired (wake-only): "${firedSchedule.name}" (${firedSchedule.id}) run #${firedSchedule.runNumber}`,
175
207
  })
176
208
 
177
209
  dispatchWake({
178
210
  mode: 'immediate',
179
- agentId: schedule.agentId,
211
+ agentId: firedSchedule.agentId,
180
212
  ...(wakeSessionId ? { sessionId: wakeSessionId } : {}),
181
- eventId: `${schedule.id}:${schedule.runNumber}`,
213
+ eventId: `${firedSchedule.id}:${firedSchedule.runNumber}`,
182
214
  reason: 'schedule',
183
- source: `schedule:${schedule.id}`,
215
+ source: `schedule:${firedSchedule.id}`,
184
216
  resumeMessage: wakeMessage,
185
- detail: `Run #${schedule.runNumber} (wake-only).`,
217
+ detail: `Run #${firedSchedule.runNumber} (wake-only).`,
186
218
  })
187
- } else if (shouldLaunchScheduleProtocol(schedule)) {
188
- upsertSchedule(schedule.id, schedule)
189
- if (hasActiveProtocolRunForSchedule(schedule.id)) continue
190
- const run = launchProtocolRunForSchedule(schedule)
219
+ } else if (shouldLaunchScheduleProtocol(firedSchedule)) {
220
+ upsertSchedule(firedSchedule.id, firedSchedule)
221
+ if (hasActiveProtocolRunForSchedule(firedSchedule.id)) continue
222
+ const run = launchProtocolRunForSchedule(firedSchedule)
191
223
  pushMainLoopEventToMainSessions({
192
224
  type: 'schedule_fired',
193
- text: `Schedule fired: "${schedule.name}" (${schedule.id}) run #${schedule.runNumber} — structured session ${run.id}`,
225
+ text: `Schedule fired: "${firedSchedule.name}" (${firedSchedule.id}) run #${firedSchedule.runNumber} — structured session ${run.id}`,
194
226
  })
195
227
  } else {
196
228
  // Default task mode: create a board task
197
229
  const { taskId } = prepareScheduledTaskRun({
198
- schedule,
230
+ schedule: firedSchedule,
199
231
  tasks,
200
232
  now,
201
233
  scheduleSignature,
202
234
  })
203
235
 
204
236
  upsertTask(taskId, tasks[taskId])
205
- upsertSchedule(schedule.id, schedule)
237
+ upsertSchedule(firedSchedule.id, firedSchedule)
206
238
 
207
239
  enqueueTask(taskId)
208
240
  if (scheduleSignature) inFlightScheduleKeys.add(scheduleSignature)
209
241
  pushMainLoopEventToMainSessions({
210
242
  type: 'schedule_fired',
211
- text: `Schedule fired: "${schedule.name}" (${schedule.id}) run #${schedule.runNumber} — task ${taskId}`,
243
+ text: `Schedule fired: "${firedSchedule.name}" (${firedSchedule.id}) run #${firedSchedule.runNumber} — task ${taskId}`,
212
244
  })
213
245
  }
214
246
  }
@@ -0,0 +1,121 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import {
5
+ applyScheduleCreationHistory,
6
+ applyScheduleUpdateHistory,
7
+ appendScheduleHistoryEntry,
8
+ normalizeScheduleHistory,
9
+ } from '@/lib/server/schedules/schedule-history'
10
+ import type { Schedule } from '@/types'
11
+
12
+ function makeSchedule(overrides: Partial<Schedule> = {}): Schedule {
13
+ return {
14
+ id: 'sched-1',
15
+ name: 'Morning run',
16
+ agentId: 'agent-1',
17
+ taskPrompt: 'Do the thing',
18
+ scheduleType: 'cron',
19
+ cron: '40 10 * * *',
20
+ timezone: 'UTC',
21
+ status: 'active',
22
+ createdAt: 0,
23
+ updatedAt: 0,
24
+ nextRunAt: Date.parse('2026-01-01T10:40:00.000Z'),
25
+ ...overrides,
26
+ }
27
+ }
28
+
29
+ describe('schedule history', () => {
30
+ it('adds a creation entry with revision 1', () => {
31
+ const schedule = applyScheduleCreationHistory(makeSchedule(), {
32
+ now: 1_000,
33
+ actor: 'user',
34
+ createId: () => 'hist-1',
35
+ })
36
+
37
+ assert.equal(schedule.revision, 1)
38
+ assert.equal(schedule.history?.length, 1)
39
+ assert.equal(schedule.history?.[0]?.id, 'hist-1')
40
+ assert.equal(schedule.history?.[0]?.action, 'created')
41
+ assert.equal(schedule.history?.[0]?.revision, 1)
42
+ assert.match(schedule.history?.[0]?.summary || '', /Morning run/)
43
+ })
44
+
45
+ it('records meaningful update changes and ignores bookkeeping fields', () => {
46
+ const current = makeSchedule({
47
+ revision: 1,
48
+ history: [{
49
+ id: 'hist-1',
50
+ at: 1_000,
51
+ actor: 'user',
52
+ action: 'created',
53
+ revision: 1,
54
+ summary: 'Schedule created: "Morning run"',
55
+ }],
56
+ })
57
+ const next = {
58
+ ...current,
59
+ name: 'Morning release run',
60
+ cron: '45 10 * * *',
61
+ updatedAt: 2_000,
62
+ }
63
+
64
+ const withHistory = applyScheduleUpdateHistory(current, next, {
65
+ now: 2_000,
66
+ actor: 'user',
67
+ createId: () => 'hist-2',
68
+ })
69
+
70
+ assert.equal(withHistory.revision, 2)
71
+ assert.equal(withHistory.history?.length, 2)
72
+ assert.equal(withHistory.history?.[0]?.id, 'hist-2')
73
+ assert.deepEqual(withHistory.history?.[0]?.changes?.map((change) => change.field), ['name', 'cron'])
74
+ })
75
+
76
+ it('does not append history for no-op updates', () => {
77
+ const current = makeSchedule({
78
+ revision: 1,
79
+ history: [{
80
+ id: 'hist-1',
81
+ at: 1_000,
82
+ actor: 'user',
83
+ action: 'created',
84
+ revision: 1,
85
+ summary: 'Schedule created: "Morning run"',
86
+ }],
87
+ })
88
+
89
+ const withHistory = applyScheduleUpdateHistory(current, {
90
+ ...current,
91
+ updatedAt: 2_000,
92
+ }, {
93
+ now: 2_000,
94
+ actor: 'user',
95
+ createId: () => 'hist-2',
96
+ })
97
+
98
+ assert.equal(withHistory.revision, 1)
99
+ assert.equal(withHistory.history?.length, 1)
100
+ assert.equal(withHistory.history?.[0]?.id, 'hist-1')
101
+ })
102
+
103
+ it('keeps only the latest retained entries', () => {
104
+ let schedule = makeSchedule()
105
+ for (let index = 0; index < 30; index += 1) {
106
+ schedule = appendScheduleHistoryEntry(schedule, {
107
+ now: index,
108
+ actor: 'system',
109
+ action: 'updated',
110
+ summary: `entry ${index}`,
111
+ createId: () => `hist-${index}`,
112
+ })
113
+ }
114
+
115
+ const history = normalizeScheduleHistory(schedule.history)
116
+ assert.equal(history.length, 25)
117
+ assert.equal(history[0].id, 'hist-29')
118
+ assert.equal(history[24].id, 'hist-5')
119
+ assert.equal(schedule.revision, 30)
120
+ })
121
+ })
@@ -0,0 +1,234 @@
1
+ import { genId } from '@/lib/id'
2
+ import type { Schedule, ScheduleHistoryAction, ScheduleHistoryChange, ScheduleHistoryEntry } from '@/types'
3
+
4
+ export const SCHEDULE_HISTORY_LIMIT = 25
5
+
6
+ export type ScheduleHistoryActor = {
7
+ actor: string
8
+ actorId?: string | null
9
+ }
10
+
11
+ type ScheduleHistoryOptions = ScheduleHistoryActor & {
12
+ now: number
13
+ createId?: () => string
14
+ }
15
+
16
+ type ScheduleHistoryEventOptions = ScheduleHistoryOptions & {
17
+ action: ScheduleHistoryAction
18
+ summary: string
19
+ changes?: ScheduleHistoryChange[]
20
+ metadata?: Record<string, string | number | boolean | null>
21
+ }
22
+
23
+ const CHANGE_FIELDS: Array<{
24
+ field: keyof Schedule
25
+ label: string
26
+ }> = [
27
+ { field: 'name', label: 'Name' },
28
+ { field: 'status', label: 'Status' },
29
+ { field: 'agentId', label: 'Agent' },
30
+ { field: 'projectId', label: 'Project' },
31
+ { field: 'taskMode', label: 'Mode' },
32
+ { field: 'taskPrompt', label: 'Prompt' },
33
+ { field: 'message', label: 'Wake message' },
34
+ { field: 'protocolTemplateId', label: 'Protocol template' },
35
+ { field: 'scheduleType', label: 'Cadence type' },
36
+ { field: 'cron', label: 'Cron' },
37
+ { field: 'intervalMs', label: 'Interval' },
38
+ { field: 'runAt', label: 'Run at' },
39
+ { field: 'timezone', label: 'Timezone' },
40
+ { field: 'staggerSec', label: 'Stagger' },
41
+ { field: 'action', label: 'Action' },
42
+ { field: 'path', label: 'Path' },
43
+ { field: 'command', label: 'Command' },
44
+ { field: 'description', label: 'Description' },
45
+ { field: 'frequency', label: 'Frequency' },
46
+ { field: 'followupConnectorId', label: 'Connector' },
47
+ { field: 'followupChannelId', label: 'Channel' },
48
+ { field: 'followupThreadId', label: 'Thread' },
49
+ { field: 'followupSenderName', label: 'Sender' },
50
+ ]
51
+
52
+ const HISTORY_ACTIONS = new Set<ScheduleHistoryAction>([
53
+ 'created',
54
+ 'updated',
55
+ 'archived',
56
+ 'restored',
57
+ 'run_started',
58
+ 'skipped',
59
+ 'failed',
60
+ ])
61
+
62
+ function cleanActor(value: string): string {
63
+ const actor = value.trim()
64
+ return actor || 'system'
65
+ }
66
+
67
+ function createHistoryId(options: ScheduleHistoryOptions): string {
68
+ return options.createId ? options.createId() : genId()
69
+ }
70
+
71
+ function normalizeHistoryEntry(value: unknown): ScheduleHistoryEntry | null {
72
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
73
+ const entry = value as Partial<ScheduleHistoryEntry>
74
+ if (typeof entry.id !== 'string' || !entry.id.trim()) return null
75
+ if (typeof entry.at !== 'number' || !Number.isFinite(entry.at)) return null
76
+ if (typeof entry.action !== 'string' || !HISTORY_ACTIONS.has(entry.action as ScheduleHistoryAction)) return null
77
+ if (typeof entry.summary !== 'string' || !entry.summary.trim()) return null
78
+ const actor = typeof entry.actor === 'string' && entry.actor.trim() ? entry.actor.trim() : 'system'
79
+ const revision = typeof entry.revision === 'number' && Number.isFinite(entry.revision)
80
+ ? Math.max(1, Math.trunc(entry.revision))
81
+ : 1
82
+ const changes = Array.isArray(entry.changes)
83
+ ? entry.changes
84
+ .map((change) => {
85
+ if (!change || typeof change !== 'object' || Array.isArray(change)) return null
86
+ const candidate = change as Partial<ScheduleHistoryChange>
87
+ if (typeof candidate.field !== 'string' || !candidate.field.trim()) return null
88
+ if (typeof candidate.label !== 'string' || !candidate.label.trim()) return null
89
+ return {
90
+ field: candidate.field.trim(),
91
+ label: candidate.label.trim(),
92
+ before: candidate.before == null ? null : String(candidate.before),
93
+ after: candidate.after == null ? null : String(candidate.after),
94
+ }
95
+ })
96
+ .filter((change): change is ScheduleHistoryChange => Boolean(change))
97
+ : undefined
98
+ const metadata = entry.metadata && typeof entry.metadata === 'object' && !Array.isArray(entry.metadata)
99
+ ? Object.fromEntries(
100
+ Object.entries(entry.metadata).filter(([, metadataValue]) =>
101
+ metadataValue == null
102
+ || typeof metadataValue === 'string'
103
+ || typeof metadataValue === 'number'
104
+ || typeof metadataValue === 'boolean',
105
+ ),
106
+ )
107
+ : undefined
108
+
109
+ return {
110
+ id: entry.id.trim(),
111
+ at: Math.trunc(entry.at),
112
+ actor,
113
+ actorId: typeof entry.actorId === 'string' && entry.actorId.trim() ? entry.actorId.trim() : null,
114
+ action: entry.action as ScheduleHistoryAction,
115
+ revision,
116
+ summary: entry.summary.trim(),
117
+ ...(changes && changes.length > 0 ? { changes } : {}),
118
+ ...(metadata && Object.keys(metadata).length > 0 ? { metadata } : {}),
119
+ }
120
+ }
121
+
122
+ export function normalizeScheduleHistory(value: unknown): ScheduleHistoryEntry[] {
123
+ if (!Array.isArray(value)) return []
124
+ return value
125
+ .map(normalizeHistoryEntry)
126
+ .filter((entry): entry is ScheduleHistoryEntry => Boolean(entry))
127
+ .sort((left, right) => right.at - left.at || right.revision - left.revision)
128
+ .slice(0, SCHEDULE_HISTORY_LIMIT)
129
+ }
130
+
131
+ function normalizeRevision(value: unknown, history: ScheduleHistoryEntry[]): number {
132
+ const explicit = typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : 0
133
+ const fromHistory = history.reduce((max, entry) => Math.max(max, entry.revision), 0)
134
+ return Math.max(explicit, fromHistory, 0)
135
+ }
136
+
137
+ function compactString(value: string, maxLength = 240): string {
138
+ const compacted = value.replace(/\s+/g, ' ').trim()
139
+ if (compacted.length <= maxLength) return compacted
140
+ return `${compacted.slice(0, maxLength - 1)}...`
141
+ }
142
+
143
+ function formatHistoryValue(value: unknown): string | null {
144
+ if (value == null) return null
145
+ if (typeof value === 'string') return compactString(value) || null
146
+ if (typeof value === 'number') return Number.isFinite(value) ? String(Math.trunc(value)) : null
147
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
148
+ if (Array.isArray(value)) {
149
+ const values = value
150
+ .map((entry) => formatHistoryValue(entry))
151
+ .filter((entry): entry is string => Boolean(entry))
152
+ return values.length > 0 ? compactString(values.join(', ')) : null
153
+ }
154
+ if (typeof value === 'object') return compactString(JSON.stringify(value))
155
+ return null
156
+ }
157
+
158
+ export function diffScheduleHistoryChanges(previous: Schedule, next: Schedule): ScheduleHistoryChange[] {
159
+ const changes: ScheduleHistoryChange[] = []
160
+ for (const item of CHANGE_FIELDS) {
161
+ const before = formatHistoryValue(previous[item.field])
162
+ const after = formatHistoryValue(next[item.field])
163
+ if (before === after) continue
164
+ changes.push({
165
+ field: String(item.field),
166
+ label: item.label,
167
+ before,
168
+ after,
169
+ })
170
+ }
171
+ return changes
172
+ }
173
+
174
+ export function appendScheduleHistoryEntry(
175
+ schedule: Schedule,
176
+ options: ScheduleHistoryEventOptions,
177
+ ): Schedule {
178
+ const history = normalizeScheduleHistory(schedule.history)
179
+ const revision = normalizeRevision(schedule.revision, history) + 1
180
+ const entry: ScheduleHistoryEntry = {
181
+ id: createHistoryId(options),
182
+ at: Math.trunc(options.now),
183
+ actor: cleanActor(options.actor),
184
+ actorId: options.actorId || null,
185
+ action: options.action,
186
+ revision,
187
+ summary: compactString(options.summary),
188
+ ...(options.changes && options.changes.length > 0 ? { changes: options.changes } : {}),
189
+ ...(options.metadata && Object.keys(options.metadata).length > 0 ? { metadata: options.metadata } : {}),
190
+ }
191
+ const nextHistory = [entry, ...history].slice(0, SCHEDULE_HISTORY_LIMIT)
192
+ return {
193
+ ...schedule,
194
+ revision,
195
+ history: nextHistory,
196
+ }
197
+ }
198
+
199
+ export function applyScheduleCreationHistory(
200
+ schedule: Schedule,
201
+ options: ScheduleHistoryOptions,
202
+ ): Schedule {
203
+ if (normalizeScheduleHistory(schedule.history).length > 0 || (schedule.revision || 0) > 0) return schedule
204
+ return appendScheduleHistoryEntry(schedule, {
205
+ ...options,
206
+ action: 'created',
207
+ summary: `Schedule created: "${schedule.name}"`,
208
+ })
209
+ }
210
+
211
+ export function applyScheduleUpdateHistory(
212
+ previous: Schedule,
213
+ next: Schedule,
214
+ options: ScheduleHistoryOptions & { summary?: string },
215
+ ): Schedule {
216
+ const changes = diffScheduleHistoryChanges(previous, next)
217
+ if (changes.length === 0) {
218
+ return {
219
+ ...next,
220
+ revision: normalizeRevision(next.revision ?? previous.revision, normalizeScheduleHistory(next.history ?? previous.history)),
221
+ history: normalizeScheduleHistory(next.history ?? previous.history),
222
+ }
223
+ }
224
+ return appendScheduleHistoryEntry({
225
+ ...next,
226
+ revision: previous.revision,
227
+ history: next.history ?? previous.history,
228
+ }, {
229
+ ...options,
230
+ action: 'updated',
231
+ summary: options.summary || `Schedule updated: "${next.name}"`,
232
+ changes,
233
+ })
234
+ }
@@ -18,6 +18,7 @@ import {
18
18
  } from '@/lib/server/storage'
19
19
  import { notify } from '@/lib/server/ws-hub'
20
20
  import { getScheduleClusterIds } from '@/lib/server/schedules/schedule-service'
21
+ import { appendScheduleHistoryEntry } from '@/lib/server/schedules/schedule-history'
21
22
 
22
23
  type RestorableScheduleStatus = Exclude<ScheduleStatus, 'archived'>
23
24
 
@@ -205,7 +206,7 @@ export function archiveScheduleCluster(
205
206
  const previousStatus = schedule.status === 'archived'
206
207
  ? (schedule.archivedFromStatus || 'active')
207
208
  : schedule.status
208
- schedules[id] = {
209
+ const archivedSchedule: Schedule = {
209
210
  ...cloneSchedule(schedule),
210
211
  status: 'archived',
211
212
  archivedAt: schedule.archivedAt || now,
@@ -213,6 +214,24 @@ export function archiveScheduleCluster(
213
214
  nextRunAt: undefined,
214
215
  updatedAt: now,
215
216
  }
217
+ schedules[id] = appendScheduleHistoryEntry(archivedSchedule, {
218
+ now,
219
+ actor: opts.actor?.actor || 'system',
220
+ actorId: opts.actor?.actorId || null,
221
+ action: 'archived',
222
+ summary: `Schedule archived: "${schedule.name}"`,
223
+ changes: [{
224
+ field: 'status',
225
+ label: 'Status',
226
+ before: previousStatus,
227
+ after: 'archived',
228
+ }],
229
+ metadata: {
230
+ cancelledTaskCount: cancelledTaskIds.length,
231
+ removedQueuedTaskCount: removedQueuedTaskIds.length,
232
+ abortedRunSessionCount: abortedRunSessionIds.length,
233
+ },
234
+ })
216
235
  }
217
236
 
218
237
  if (tasksDirty) {
@@ -289,7 +308,7 @@ export function restoreArchivedScheduleCluster(
289
308
  : restoreStatus === 'completed' || restoreStatus === 'failed'
290
309
  ? undefined
291
310
  : schedule.nextRunAt
292
- schedules[id] = {
311
+ const restoredSchedule: Schedule = {
293
312
  ...cloneSchedule(schedule),
294
313
  status: restoreStatus,
295
314
  archivedAt: null,
@@ -297,6 +316,19 @@ export function restoreArchivedScheduleCluster(
297
316
  nextRunAt,
298
317
  updatedAt: now,
299
318
  }
319
+ schedules[id] = appendScheduleHistoryEntry(restoredSchedule, {
320
+ now,
321
+ actor: opts.actor?.actor || 'system',
322
+ actorId: opts.actor?.actorId || null,
323
+ action: 'restored',
324
+ summary: `Schedule restored: "${schedule.name}"`,
325
+ changes: [{
326
+ field: 'status',
327
+ label: 'Status',
328
+ before: 'archived',
329
+ after: restoreStatus,
330
+ }],
331
+ })
300
332
  restoredIds.push(id)
301
333
  }
302
334
 
@@ -18,6 +18,7 @@ import { loadTasks, saveTask } from '@/lib/server/tasks/task-repository'
18
18
  import { pushMainLoopEventToMainSessions } from '@/lib/server/agents/main-agent-loop'
19
19
  import { WORKSPACE_DIR } from '@/lib/server/data-dir'
20
20
  import { notify } from '@/lib/server/ws-hub'
21
+ import { appendScheduleHistoryEntry } from '@/lib/server/schedules/schedule-history'
21
22
  import type { Schedule } from '@/types'
22
23
  import type { ScheduleLike } from '@/lib/schedules/schedule-dedupe'
23
24
  import type { ServiceResult } from '@/lib/server/service-result'
@@ -55,6 +56,7 @@ export function createScheduleFromRoute(body: Record<string, unknown>): ServiceR
55
56
  schedules,
56
57
  now,
57
58
  cwd: WORKSPACE_DIR,
59
+ historyActor: { actor: 'user' },
58
60
  })
59
61
  if (!prepared.ok) {
60
62
  return serviceFail(400, prepared.error)
@@ -125,6 +127,7 @@ export function updateScheduleFromRoute(id: string, body: Record<string, unknown
125
127
  agentExists: (agentId) => Boolean(agents[agentId]),
126
128
  propagateEquivalentStatuses: true,
127
129
  propagationSource: current as unknown as Record<string, unknown>,
130
+ historyActor: { actor: 'user' },
128
131
  })
129
132
  if (!prepared.ok) {
130
133
  return serviceFail(400, errorMessage(prepared.error))
@@ -216,7 +219,14 @@ export function runScheduleNow(id: string): ServiceResult<Record<string, unknown
216
219
  text: `Schedule fired manually: "${schedule.name}" (${schedule.id}) run #${schedule.runNumber} — task ${taskId}`,
217
220
  })
218
221
  schedule.lastRunAt = now
219
- upsertSchedule(schedule.id, schedule)
222
+ const scheduleWithHistory = appendScheduleHistoryEntry(schedule, {
223
+ now,
224
+ actor: 'user',
225
+ action: 'run_started',
226
+ summary: `Manual schedule run queued: "${schedule.name}"`,
227
+ metadata: { taskId, runNumber: schedule.runNumber || 0 },
228
+ })
229
+ upsertSchedule(schedule.id, scheduleWithHistory)
220
230
  logActivity({
221
231
  entityType: 'schedule',
222
232
  entityId: schedule.id,