@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.
- package/README.md +65 -0
- package/package.json +2 -2
- package/src/app/api/schedules/[id]/history/route.ts +15 -0
- package/src/app/api/schedules/[id]/route.test.ts +52 -3
- package/src/app/api/schedules/schedule-history-route.test.ts +60 -0
- package/src/cli/index.js +1 -0
- package/src/cli/index.ts +8 -0
- package/src/cli/spec.js +1 -0
- package/src/components/schedules/schedule-console.tsx +173 -15
- package/src/lib/schedules/schedules.ts +10 -1
- package/src/lib/server/runtime/scheduler.ts +52 -20
- package/src/lib/server/schedules/schedule-history.test.ts +121 -0
- package/src/lib/server/schedules/schedule-history.ts +234 -0
- package/src/lib/server/schedules/schedule-lifecycle.ts +34 -2
- package/src/lib/server/schedules/schedule-route-service.ts +11 -1
- package/src/lib/server/schedules/schedule-service.ts +39 -7
- package/src/lib/server/session-tools/crud.ts +2 -0
- package/src/lib/server/storage-normalization.ts +15 -0
- package/src/types/schedule.ts +22 -0
|
@@ -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(
|
|
198
|
+
if (shouldWakeScheduleSession(firedSchedule)) {
|
|
167
199
|
// Wake-only: no board task, just heartbeat the agent
|
|
168
|
-
upsertSchedule(
|
|
169
|
-
const wakeSessionId = resolveScheduleWakeSessionId(
|
|
200
|
+
upsertSchedule(firedSchedule.id, firedSchedule)
|
|
201
|
+
const wakeSessionId = resolveScheduleWakeSessionId(firedSchedule, agents as Record<string, unknown>)
|
|
170
202
|
|
|
171
|
-
const wakeMessage =
|
|
203
|
+
const wakeMessage = firedSchedule.message || `Schedule triggered: ${firedSchedule.name}`
|
|
172
204
|
pushMainLoopEventToMainSessions({
|
|
173
205
|
type: 'schedule_fired',
|
|
174
|
-
text: `Schedule fired (wake-only): "${
|
|
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:
|
|
211
|
+
agentId: firedSchedule.agentId,
|
|
180
212
|
...(wakeSessionId ? { sessionId: wakeSessionId } : {}),
|
|
181
|
-
eventId: `${
|
|
213
|
+
eventId: `${firedSchedule.id}:${firedSchedule.runNumber}`,
|
|
182
214
|
reason: 'schedule',
|
|
183
|
-
source: `schedule:${
|
|
215
|
+
source: `schedule:${firedSchedule.id}`,
|
|
184
216
|
resumeMessage: wakeMessage,
|
|
185
|
-
detail: `Run #${
|
|
217
|
+
detail: `Run #${firedSchedule.runNumber} (wake-only).`,
|
|
186
218
|
})
|
|
187
|
-
} else if (shouldLaunchScheduleProtocol(
|
|
188
|
-
upsertSchedule(
|
|
189
|
-
if (hasActiveProtocolRunForSchedule(
|
|
190
|
-
const run = launchProtocolRunForSchedule(
|
|
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: "${
|
|
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(
|
|
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: "${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|