@swarmclawai/swarmclaw 1.9.16 → 1.9.18
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 +26 -0
- package/package.json +2 -2
- package/src/app/api/config-versions/config-versions-route.test.ts +81 -0
- package/src/app/api/schedules/preview/route.test.ts +48 -0
- package/src/app/api/schedules/preview/route.ts +14 -0
- package/src/cli/index.js +1 -0
- package/src/cli/spec.js +1 -0
- package/src/components/agents/agent-sheet.tsx +119 -0
- package/src/components/schedules/schedule-console.tsx +2 -1
- package/src/components/schedules/schedule-sheet.tsx +190 -49
- package/src/lib/agent-config-history.test.ts +50 -0
- package/src/lib/agent-config-history.ts +48 -0
- package/src/lib/schedules/schedules.ts +4 -1
- package/src/lib/server/schedules/schedule-normalization.ts +17 -1
- package/src/lib/server/schedules/schedule-preview.test.ts +89 -0
- package/src/lib/server/schedules/schedule-preview.ts +171 -0
- package/src/lib/server/schedules/schedule-route-service.ts +25 -1
- package/src/types/schedule.ts +23 -0
package/README.md
CHANGED
|
@@ -149,6 +149,14 @@ clawhub install swarmclaw
|
|
|
149
149
|
|
|
150
150
|
[Browse on ClawHub](https://clawhub.ai/skills/swarmclaw)
|
|
151
151
|
|
|
152
|
+
## v1.9.17 Highlights
|
|
153
|
+
|
|
154
|
+
Agent configuration history is now visible in the agent editor, so operators can review recent saved versions and restore prior settings without leaving the agent workflow.
|
|
155
|
+
|
|
156
|
+
- **Agent sheet history.** Advanced agent settings list recent saved versions with timestamp, actor, and provider/model snapshot.
|
|
157
|
+
- **One-click restore.** Restoring a prior version uses the existing config-version restore API, refreshes agent state, and closes the sheet to avoid stale form data.
|
|
158
|
+
- **Regression coverage.** New tests cover config-version list/restore routes and UI summary formatting.
|
|
159
|
+
|
|
152
160
|
## Hosted Deploys
|
|
153
161
|
|
|
154
162
|
SwarmClaw now ships provider-ready deploy files at the repo root:
|
|
@@ -399,6 +407,24 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
399
407
|
|
|
400
408
|
## Releases
|
|
401
409
|
|
|
410
|
+
### v1.9.18 Highlights
|
|
411
|
+
|
|
412
|
+
Schedule preflight release: schedules now show server-backed timing forecasts before save, with timezone-aware cron previews and warnings for risky drafts.
|
|
413
|
+
|
|
414
|
+
- **Schedule preview API.** `POST /api/schedules/preview` validates a draft schedule through the same normalization path as saved schedules and returns the next calculated runs.
|
|
415
|
+
- **Timezone-aware schedule sheet.** Cron schedules can set an explicit timezone, preview the next runs from the server, and see warnings before saving.
|
|
416
|
+
- **Stagger and one-shot controls.** Operators can add a stagger window to recurring schedules and choose a run-once delay from the schedule workflow.
|
|
417
|
+
- **CLI access.** `swarmclaw schedules preview --data '{...}'` exposes the same forecast for scripts and release automation.
|
|
418
|
+
|
|
419
|
+
### v1.9.17 Highlights
|
|
420
|
+
|
|
421
|
+
Agent configuration history release: SwarmClaw now surfaces saved agent versions directly in the agent editor, giving operators a fast rollback path for agent settings.
|
|
422
|
+
|
|
423
|
+
- **Agent sheet history.** Advanced settings list recent saved versions with relative time, actor, and provider/model snapshot.
|
|
424
|
+
- **One-click restore.** Operators can restore a prior agent configuration through the existing version-restore API without leaving the agent workflow.
|
|
425
|
+
- **Stale-form protection.** Successful restore reloads agent state and closes the sheet so operators reopen the refreshed record.
|
|
426
|
+
- **Regression coverage.** New tests cover config-version list/restore routes and summary formatting.
|
|
427
|
+
|
|
402
428
|
### v1.9.16 Highlights
|
|
403
429
|
|
|
404
430
|
Agent planning controls release: strict planning is now a first-class agent setting instead of a hidden persisted field, so operators can decide which agents must expose machine-readable plans before multi-step work.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.18",
|
|
4
4
|
"description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
|
|
5
5
|
"main": "electron-dist/main.js",
|
|
6
6
|
"license": "MIT",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/ensure-sandbox-browser-image.test.mjs scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
|
|
89
89
|
"test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
|
|
90
90
|
"test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/gateways/gateway-topology.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/gateways/topology-route.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
|
|
91
|
-
"test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/agent-planning-mode.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/prompt-sections.planning-mode.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/chats/session-context-pack.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/runs/run-handoff.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/quality/release-readiness.test.ts src/lib/quality/architecture-health.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-execution-policy.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-pack-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/runs/run-handoff-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
|
|
91
|
+
"test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/agent-planning-mode.test.ts src/lib/agent-config-history.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/prompt-sections.planning-mode.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/chats/session-context-pack.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/runs/run-handoff.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/server/schedules/schedule-preview.test.ts src/lib/quality/release-readiness.test.ts src/lib/quality/architecture-health.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-execution-policy.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-pack-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/config-versions/config-versions-route.test.ts src/app/api/runs/run-handoff-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/preview/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
|
|
92
92
|
"test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
|
|
93
93
|
"test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
|
|
94
94
|
"test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
|
|
5
|
+
|
|
6
|
+
test('config version routes list and restore prior agent settings', () => {
|
|
7
|
+
const output = runWithTempDataDir<{
|
|
8
|
+
updateStatus: number
|
|
9
|
+
listStatus: number
|
|
10
|
+
restoreStatus: number
|
|
11
|
+
versionCount: number
|
|
12
|
+
versionName: string | null
|
|
13
|
+
restoredName: string | null
|
|
14
|
+
restoredModel: string | null
|
|
15
|
+
}>(`
|
|
16
|
+
const storageMod = await import('./src/lib/server/storage')
|
|
17
|
+
const agentRouteMod = await import('./src/app/api/agents/[id]/route')
|
|
18
|
+
const configRouteMod = await import('./src/app/api/config-versions/route')
|
|
19
|
+
const restoreRouteMod = await import('./src/app/api/config-versions/restore/route')
|
|
20
|
+
const storage = storageMod.default || storageMod
|
|
21
|
+
const agentRoute = agentRouteMod.default || agentRouteMod
|
|
22
|
+
const configRoute = configRouteMod.default || configRouteMod
|
|
23
|
+
const restoreRoute = restoreRouteMod.default || restoreRouteMod
|
|
24
|
+
|
|
25
|
+
const now = Date.now()
|
|
26
|
+
storage.saveAgents({
|
|
27
|
+
agent_history_1: {
|
|
28
|
+
id: 'agent_history_1',
|
|
29
|
+
name: 'Before Save',
|
|
30
|
+
description: 'Original',
|
|
31
|
+
systemPrompt: '',
|
|
32
|
+
provider: 'ollama',
|
|
33
|
+
model: 'qwen3.5',
|
|
34
|
+
credentialId: null,
|
|
35
|
+
fallbackCredentialIds: [],
|
|
36
|
+
apiEndpoint: null,
|
|
37
|
+
gatewayProfileId: null,
|
|
38
|
+
extensions: [],
|
|
39
|
+
createdAt: now,
|
|
40
|
+
updatedAt: now,
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const params = { params: Promise.resolve({ id: 'agent_history_1' }) }
|
|
45
|
+
const updateResponse = await agentRoute.PUT(new Request('http://local/api/agents/agent_history_1', {
|
|
46
|
+
method: 'PUT',
|
|
47
|
+
headers: { 'content-type': 'application/json' },
|
|
48
|
+
body: JSON.stringify({ name: 'After Save', model: 'qwen3.6' }),
|
|
49
|
+
}), params)
|
|
50
|
+
|
|
51
|
+
const listResponse = await configRoute.GET(new Request('http://local/api/config-versions?entityKind=agent&entityId=agent_history_1'))
|
|
52
|
+
const listPayload = await listResponse.json()
|
|
53
|
+
const firstVersion = listPayload.versions?.[0] || null
|
|
54
|
+
|
|
55
|
+
const restoreResponse = await restoreRoute.POST(new Request('http://local/api/config-versions/restore', {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'content-type': 'application/json' },
|
|
58
|
+
body: JSON.stringify({ versionId: firstVersion?.id }),
|
|
59
|
+
}))
|
|
60
|
+
|
|
61
|
+
const restored = storage.loadAgents().agent_history_1
|
|
62
|
+
|
|
63
|
+
console.log(JSON.stringify({
|
|
64
|
+
updateStatus: updateResponse.status,
|
|
65
|
+
listStatus: listResponse.status,
|
|
66
|
+
restoreStatus: restoreResponse.status,
|
|
67
|
+
versionCount: Array.isArray(listPayload.versions) ? listPayload.versions.length : 0,
|
|
68
|
+
versionName: firstVersion?.snapshot?.name || null,
|
|
69
|
+
restoredName: restored?.name || null,
|
|
70
|
+
restoredModel: restored?.model || null,
|
|
71
|
+
}))
|
|
72
|
+
`, { prefix: 'swarmclaw-config-versions-route-' })
|
|
73
|
+
|
|
74
|
+
assert.equal(output.updateStatus, 200)
|
|
75
|
+
assert.equal(output.listStatus, 200)
|
|
76
|
+
assert.equal(output.restoreStatus, 200)
|
|
77
|
+
assert.equal(output.versionCount, 1)
|
|
78
|
+
assert.equal(output.versionName, 'Before Save')
|
|
79
|
+
assert.equal(output.restoredName, 'Before Save')
|
|
80
|
+
assert.equal(output.restoredModel, 'qwen3.5')
|
|
81
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { POST as previewSchedule } from './route'
|
|
5
|
+
|
|
6
|
+
test('POST /api/schedules/preview returns a timing forecast without persisting', async () => {
|
|
7
|
+
const agentId = `missing-preview-agent-${Date.now()}`
|
|
8
|
+
const response = await previewSchedule(new Request('http://local/api/schedules/preview', {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
headers: { 'content-type': 'application/json' },
|
|
11
|
+
body: JSON.stringify({
|
|
12
|
+
agentId,
|
|
13
|
+
name: 'Preview smoke',
|
|
14
|
+
taskPrompt: 'Review the queue',
|
|
15
|
+
scheduleType: 'cron',
|
|
16
|
+
cron: '0 9 * * *',
|
|
17
|
+
timezone: 'UTC',
|
|
18
|
+
status: 'active',
|
|
19
|
+
}),
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
assert.equal(response.status, 200)
|
|
23
|
+
const payload = await response.json() as Record<string, unknown>
|
|
24
|
+
assert.equal(payload.ok, true)
|
|
25
|
+
assert.equal(payload.scheduleType, 'cron')
|
|
26
|
+
assert.equal(payload.timezone, 'UTC')
|
|
27
|
+
assert.equal(Array.isArray(payload.nextRuns), true)
|
|
28
|
+
assert.equal((payload.nextRuns as unknown[]).length, 5)
|
|
29
|
+
assert.match(String((payload.warnings as string[]).join('\n')), /Agent not found/)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('POST /api/schedules/preview rejects invalid draft timing', async () => {
|
|
33
|
+
const response = await previewSchedule(new Request('http://local/api/schedules/preview', {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'content-type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({
|
|
37
|
+
agentId: 'schedule-preview-agent-invalid',
|
|
38
|
+
taskPrompt: 'Review the queue',
|
|
39
|
+
scheduleType: 'cron',
|
|
40
|
+
cron: '0 9 * * *',
|
|
41
|
+
timezone: 'Not/AZone',
|
|
42
|
+
}),
|
|
43
|
+
}))
|
|
44
|
+
|
|
45
|
+
assert.equal(response.status, 400)
|
|
46
|
+
const payload = await response.json() as Record<string, unknown>
|
|
47
|
+
assert.match(String(payload.error || ''), /invalid timezone/)
|
|
48
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
3
|
+
import { previewScheduleFromRoute } from '@/lib/server/schedules/schedule-route-service'
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic'
|
|
6
|
+
|
|
7
|
+
export async function POST(req: Request) {
|
|
8
|
+
const { data: body, error } = await safeParseBody(req)
|
|
9
|
+
if (error) return error
|
|
10
|
+
const result = previewScheduleFromRoute((body || {}) as Record<string, unknown>)
|
|
11
|
+
return result.ok
|
|
12
|
+
? NextResponse.json(result.payload)
|
|
13
|
+
: NextResponse.json(result.payload, { status: result.status })
|
|
14
|
+
}
|
package/src/cli/index.js
CHANGED
|
@@ -582,6 +582,7 @@ const COMMAND_GROUPS = [
|
|
|
582
582
|
cmd('list', 'GET', '/schedules', 'List schedules'),
|
|
583
583
|
cmd('get', 'GET', '/schedules/:id', 'Get schedule by id'),
|
|
584
584
|
cmd('history', 'GET', '/schedules/:id/history', 'Get schedule revision history'),
|
|
585
|
+
cmd('preview', 'POST', '/schedules/preview', 'Preview schedule timing and validation', { expectsJsonBody: true }),
|
|
585
586
|
cmd('create', 'POST', '/schedules', 'Create schedule', { expectsJsonBody: true }),
|
|
586
587
|
cmd('update', 'PUT', '/schedules/:id', 'Update schedule', { expectsJsonBody: true }),
|
|
587
588
|
cmd('delete', 'DELETE', '/schedules/:id', 'Delete schedule'),
|
package/src/cli/spec.js
CHANGED
|
@@ -418,6 +418,7 @@ const COMMAND_GROUPS = {
|
|
|
418
418
|
description: 'Scheduled task automation',
|
|
419
419
|
commands: {
|
|
420
420
|
list: { description: 'List schedules', method: 'GET', path: '/schedules' },
|
|
421
|
+
preview: { description: 'Preview schedule timing and validation', method: 'POST', path: '/schedules/preview' },
|
|
421
422
|
create: { description: 'Create schedule', method: 'POST', path: '/schedules' },
|
|
422
423
|
get: { description: 'Get schedule by id (from list)', virtualGet: true, collectionPath: '/schedules', params: ['id'] },
|
|
423
424
|
history: { description: 'Get schedule revision history', method: 'GET', path: '/schedules/:id/history', params: ['id'] },
|
|
@@ -27,10 +27,12 @@ import { resolveStoredOllamaMode } from '@/lib/ollama-mode'
|
|
|
27
27
|
import { errorMessage } from '@/lib/shared-utils'
|
|
28
28
|
import { getDefaultAgentToolIds } from '@/lib/agent-default-tools'
|
|
29
29
|
import { AGENT_PLANNING_MODE_OPTIONS, describeAgentPlanningMode, normalizeAgentPlanningMode, type AgentPlanningMode } from '@/lib/agent-planning-mode'
|
|
30
|
+
import { buildAgentConfigVersionSummary } from '@/lib/agent-config-history'
|
|
30
31
|
import { getEnabledExtensionIds, getEnabledToolIds } from '@/lib/capability-selection'
|
|
31
32
|
import { buildAgentSelectableProviders, resolveAgentSelectableProviderCredentials } from '@/lib/agent-provider-options'
|
|
32
33
|
import { AgentSocialSettings } from '@/features/swarmfeed/agent-social-settings'
|
|
33
34
|
import { AgentMarketplaceSettings } from '@/features/swarmdock/agent-marketplace-settings'
|
|
35
|
+
import type { ConfigVersion } from '@/types/config-version'
|
|
34
36
|
|
|
35
37
|
const HB_PRESETS = [1800, 3600, 7200, 21600, 43200] as const
|
|
36
38
|
const FALLBACK_ELEVENLABS_VOICE_ID = 'JBFqnCBsd6RMkjVDRZzb'
|
|
@@ -292,6 +294,10 @@ export function AgentSheet() {
|
|
|
292
294
|
const lastAutoSyncedModelsKeyRef = useRef<string | null>(null)
|
|
293
295
|
const skipAutoModelRef = useRef(false)
|
|
294
296
|
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false)
|
|
297
|
+
const [configVersions, setConfigVersions] = useState<ConfigVersion[]>([])
|
|
298
|
+
const [configVersionsLoading, setConfigVersionsLoading] = useState(false)
|
|
299
|
+
const [configVersionsError, setConfigVersionsError] = useState<string | null>(null)
|
|
300
|
+
const [restoringConfigVersionId, setRestoringConfigVersionId] = useState<string | null>(null)
|
|
295
301
|
|
|
296
302
|
const handleFileUpload = (setter: (v: string) => void) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
297
303
|
const file = e.target.files?.[0]
|
|
@@ -361,6 +367,23 @@ export function AgentSheet() {
|
|
|
361
367
|
return { synced: !sameModels, models: result.models }
|
|
362
368
|
}, [agentSelectableProviders, loadProviders, openclawEnabled])
|
|
363
369
|
|
|
370
|
+
const loadAgentConfigVersions = useCallback(async (agentId: string) => {
|
|
371
|
+
setConfigVersionsLoading(true)
|
|
372
|
+
setConfigVersionsError(null)
|
|
373
|
+
try {
|
|
374
|
+
const response = await api<{ versions: ConfigVersion[] }>(
|
|
375
|
+
'GET',
|
|
376
|
+
`/config-versions?entityKind=agent&entityId=${encodeURIComponent(agentId)}`,
|
|
377
|
+
)
|
|
378
|
+
setConfigVersions(Array.isArray(response.versions) ? response.versions : [])
|
|
379
|
+
} catch (err) {
|
|
380
|
+
setConfigVersions([])
|
|
381
|
+
setConfigVersionsError(errorMessage(err))
|
|
382
|
+
} finally {
|
|
383
|
+
setConfigVersionsLoading(false)
|
|
384
|
+
}
|
|
385
|
+
}, [])
|
|
386
|
+
|
|
364
387
|
const providerNeedsKey = !editing && (
|
|
365
388
|
(currentProvider?.requiresApiKey && providerCredentials.length === 0 && !addingKey) ||
|
|
366
389
|
(provider === 'ollama' && ollamaMode === 'cloud' && providerCredentials.length === 0 && !addingKey)
|
|
@@ -640,6 +663,17 @@ export function AgentSheet() {
|
|
|
640
663
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
641
664
|
}, [open, editingId])
|
|
642
665
|
|
|
666
|
+
useEffect(() => {
|
|
667
|
+
if (!open || !editingId) {
|
|
668
|
+
setConfigVersions([])
|
|
669
|
+
setConfigVersionsError(null)
|
|
670
|
+
setConfigVersionsLoading(false)
|
|
671
|
+
setRestoringConfigVersionId(null)
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
void loadAgentConfigVersions(editingId)
|
|
675
|
+
}, [editingId, loadAgentConfigVersions, open])
|
|
676
|
+
|
|
643
677
|
useEffect(() => {
|
|
644
678
|
if (skipAutoModelRef.current) {
|
|
645
679
|
skipAutoModelRef.current = false
|
|
@@ -891,6 +925,33 @@ export function AgentSheet() {
|
|
|
891
925
|
}
|
|
892
926
|
}
|
|
893
927
|
|
|
928
|
+
const handleRestoreConfigVersion = async (versionId: string) => {
|
|
929
|
+
if (!editing) return
|
|
930
|
+
const confirmed = window.confirm('Restore this saved agent configuration? Current settings will become a new history entry when restored.')
|
|
931
|
+
if (!confirmed) return
|
|
932
|
+
setRestoringConfigVersionId(versionId)
|
|
933
|
+
try {
|
|
934
|
+
await api('POST', '/config-versions/restore', { versionId })
|
|
935
|
+
await loadAgents()
|
|
936
|
+
if (
|
|
937
|
+
activeSessionId
|
|
938
|
+
&& currentSession?.agentId === editing.id
|
|
939
|
+
&& (
|
|
940
|
+
currentSession.shortcutForAgentId === editing.id
|
|
941
|
+
|| activeSessionId === editing.threadSessionId
|
|
942
|
+
)
|
|
943
|
+
) {
|
|
944
|
+
await refreshSession(activeSessionId)
|
|
945
|
+
}
|
|
946
|
+
toast.success('Agent configuration restored')
|
|
947
|
+
onClose()
|
|
948
|
+
} catch (err) {
|
|
949
|
+
toast.error(`Restore failed: ${errorMessage(err)}`)
|
|
950
|
+
} finally {
|
|
951
|
+
setRestoringConfigVersionId(null)
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
894
955
|
const handleExport = () => {
|
|
895
956
|
if (!editing) return
|
|
896
957
|
const recommendedProviders = agentSelectableProviders.some((providerOption) => (
|
|
@@ -1100,6 +1161,7 @@ export function AgentSheet() {
|
|
|
1100
1161
|
}
|
|
1101
1162
|
|
|
1102
1163
|
const inputClass = "w-full px-4 py-3.5 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[15px] outline-none transition-all duration-200 placeholder:text-text-3/50 focus-glow"
|
|
1164
|
+
const configVersionSummaries = configVersions.map((version) => buildAgentConfigVersionSummary(version))
|
|
1103
1165
|
|
|
1104
1166
|
return (
|
|
1105
1167
|
<>
|
|
@@ -2787,6 +2849,63 @@ export function AgentSheet() {
|
|
|
2787
2849
|
</div>
|
|
2788
2850
|
</SectionCard>
|
|
2789
2851
|
|
|
2852
|
+
{editing && (
|
|
2853
|
+
<SectionCard
|
|
2854
|
+
title="Configuration History"
|
|
2855
|
+
description="Recent saved versions for this agent."
|
|
2856
|
+
className="mb-6 border-white/[0.05] bg-white/[0.01]"
|
|
2857
|
+
action={(
|
|
2858
|
+
<button
|
|
2859
|
+
type="button"
|
|
2860
|
+
onClick={() => void loadAgentConfigVersions(editing.id)}
|
|
2861
|
+
disabled={configVersionsLoading}
|
|
2862
|
+
className="px-3 py-2 rounded-[10px] border border-white/[0.08] bg-transparent text-[12px] font-600 text-text-3 hover:bg-white/[0.04] hover:text-text-2 transition-all disabled:opacity-50"
|
|
2863
|
+
style={{ fontFamily: 'inherit' }}
|
|
2864
|
+
>
|
|
2865
|
+
{configVersionsLoading ? 'Refreshing' : 'Refresh'}
|
|
2866
|
+
</button>
|
|
2867
|
+
)}
|
|
2868
|
+
>
|
|
2869
|
+
{configVersionsError ? (
|
|
2870
|
+
<div className="rounded-[12px] border border-red-500/20 bg-red-500/[0.06] p-3 text-[13px] text-red-300">
|
|
2871
|
+
{configVersionsError}
|
|
2872
|
+
</div>
|
|
2873
|
+
) : configVersionsLoading && configVersionSummaries.length === 0 ? (
|
|
2874
|
+
<div className="rounded-[12px] border border-white/[0.06] bg-white/[0.02] p-3 text-[13px] text-text-3">
|
|
2875
|
+
Loading saved versions...
|
|
2876
|
+
</div>
|
|
2877
|
+
) : configVersionSummaries.length === 0 ? (
|
|
2878
|
+
<div className="rounded-[12px] border border-white/[0.06] bg-white/[0.02] p-3 text-[13px] text-text-3">
|
|
2879
|
+
No saved versions yet.
|
|
2880
|
+
</div>
|
|
2881
|
+
) : (
|
|
2882
|
+
<div className="space-y-3">
|
|
2883
|
+
{configVersionSummaries.slice(0, 8).map((summary) => (
|
|
2884
|
+
<div
|
|
2885
|
+
key={summary.id}
|
|
2886
|
+
className="flex flex-col gap-3 rounded-[12px] border border-white/[0.06] bg-white/[0.02] p-3 sm:flex-row sm:items-center sm:justify-between"
|
|
2887
|
+
>
|
|
2888
|
+
<div className="min-w-0">
|
|
2889
|
+
<div className="truncate text-[14px] font-700 text-text">{summary.title}</div>
|
|
2890
|
+
<div className="mt-1 truncate text-[12px] text-text-3">{summary.subtitle}</div>
|
|
2891
|
+
<div className="mt-1 text-[11px] text-text-3/70">{summary.meta}</div>
|
|
2892
|
+
</div>
|
|
2893
|
+
<button
|
|
2894
|
+
type="button"
|
|
2895
|
+
onClick={() => void handleRestoreConfigVersion(summary.id)}
|
|
2896
|
+
disabled={Boolean(restoringConfigVersionId)}
|
|
2897
|
+
className="shrink-0 rounded-[10px] border border-accent-bright/20 bg-accent-soft/30 px-3 py-2 text-[12px] font-700 text-accent-bright transition-all hover:bg-accent-soft disabled:opacity-50"
|
|
2898
|
+
style={{ fontFamily: 'inherit' }}
|
|
2899
|
+
>
|
|
2900
|
+
{restoringConfigVersionId === summary.id ? 'Restoring' : 'Restore'}
|
|
2901
|
+
</button>
|
|
2902
|
+
</div>
|
|
2903
|
+
))}
|
|
2904
|
+
</div>
|
|
2905
|
+
)}
|
|
2906
|
+
</SectionCard>
|
|
2907
|
+
)}
|
|
2908
|
+
|
|
2790
2909
|
<SectionCard
|
|
2791
2910
|
title="Utilities"
|
|
2792
2911
|
description="Import and export agents."
|
|
@@ -59,7 +59,8 @@ function badgeClass(status: string): string {
|
|
|
59
59
|
|
|
60
60
|
function formatScheduleCadence(schedule: Schedule): string {
|
|
61
61
|
if (schedule.scheduleType === 'cron' && schedule.cron) {
|
|
62
|
-
|
|
62
|
+
const timezone = schedule.timezone ? ` (${schedule.timezone})` : ''
|
|
63
|
+
return `${cronToHuman(schedule.cron)}${timezone}`
|
|
63
64
|
}
|
|
64
65
|
if (schedule.scheduleType === 'interval' && schedule.intervalMs) {
|
|
65
66
|
const minutes = Math.round(schedule.intervalMs / 60_000)
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState, useMemo } from 'react'
|
|
4
4
|
import { useAppStore } from '@/stores/use-app-store'
|
|
5
|
-
import { createSchedule, updateSchedule, deleteSchedule } from '@/lib/schedules/schedules'
|
|
5
|
+
import { createSchedule, updateSchedule, deleteSchedule, previewSchedule } from '@/lib/schedules/schedules'
|
|
6
6
|
import { BottomSheet } from '@/components/shared/bottom-sheet'
|
|
7
7
|
import { AgentPickerList } from '@/components/shared/agent-picker-list'
|
|
8
8
|
import { ConfirmDialog } from '@/components/shared/confirm-dialog'
|
|
9
9
|
import { inputClass } from '@/components/shared/form-styles'
|
|
10
10
|
import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
11
|
-
import type { ScheduleTaskMode, ScheduleType, ScheduleStatus } from '@/types'
|
|
11
|
+
import type { Schedule, SchedulePreviewResponse, ScheduleTaskMode, ScheduleType, ScheduleStatus } from '@/types'
|
|
12
12
|
import cronstrue from 'cronstrue'
|
|
13
13
|
import { SectionLabel } from '@/components/shared/section-label'
|
|
14
14
|
import { SCHEDULE_TEMPLATES, type ScheduleTemplate } from '@/lib/schedules/schedule-templates'
|
|
@@ -32,20 +32,6 @@ const CRON_PRESETS = [
|
|
|
32
32
|
{ label: 'Weekly Mon 9am', cron: '0 9 * * 1' },
|
|
33
33
|
]
|
|
34
34
|
|
|
35
|
-
async function getNextRunsAsync(cron: string, count: number = 3): Promise<Date[]> {
|
|
36
|
-
try {
|
|
37
|
-
const { CronExpressionParser } = await import('cron-parser')
|
|
38
|
-
const interval = CronExpressionParser.parse(cron)
|
|
39
|
-
const runs: Date[] = []
|
|
40
|
-
for (let i = 0; i < count; i++) {
|
|
41
|
-
runs.push(interval.next().toDate())
|
|
42
|
-
}
|
|
43
|
-
return runs
|
|
44
|
-
} catch {
|
|
45
|
-
return []
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
35
|
function formatCronHuman(cron: string): string {
|
|
50
36
|
try {
|
|
51
37
|
return cronstrue.toString(cron, { use24HourTimeFormat: false })
|
|
@@ -54,11 +40,6 @@ function formatCronHuman(cron: string): string {
|
|
|
54
40
|
}
|
|
55
41
|
}
|
|
56
42
|
|
|
57
|
-
function formatDate(d: Date): string {
|
|
58
|
-
return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }) +
|
|
59
|
-
' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
60
|
-
}
|
|
61
|
-
|
|
62
43
|
const STEPS_CREATE = ['Template', 'What', 'When', 'Review'] as const
|
|
63
44
|
const STEPS_EDIT = ['What', 'When', 'Review'] as const
|
|
64
45
|
type Step = 0 | 1 | 2 | 3
|
|
@@ -107,6 +88,11 @@ export function ScheduleSheet() {
|
|
|
107
88
|
const [taskMode, setTaskMode] = useState<ScheduleTaskMode>('task')
|
|
108
89
|
const [message, setMessage] = useState('')
|
|
109
90
|
const [customCron, setCustomCron] = useState(false)
|
|
91
|
+
const [timezone, setTimezone] = useState('')
|
|
92
|
+
const [staggerSec, setStaggerSec] = useState(0)
|
|
93
|
+
const [timingPreview, setTimingPreview] = useState<SchedulePreviewResponse | null>(null)
|
|
94
|
+
const [timingPreviewLoading, setTimingPreviewLoading] = useState(false)
|
|
95
|
+
const [timingPreviewError, setTimingPreviewError] = useState('')
|
|
110
96
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
111
97
|
const [deleting, setDeleting] = useState(false)
|
|
112
98
|
|
|
@@ -136,6 +122,8 @@ export function ScheduleSheet() {
|
|
|
136
122
|
setTaskMode(editing.taskMode === 'wake_only' ? 'wake_only' : editing.taskMode === 'protocol' ? 'protocol' : 'task')
|
|
137
123
|
setMessage(editing.message || '')
|
|
138
124
|
setCustomCron(!CRON_PRESETS.some((p) => p.cron === editing.cron))
|
|
125
|
+
setTimezone(editing.timezone || '')
|
|
126
|
+
setStaggerSec(editing.staggerSec || 0)
|
|
139
127
|
} else if (templatePrefill) {
|
|
140
128
|
// Opened from a quick-start card with pre-filled values
|
|
141
129
|
setName(templatePrefill.name)
|
|
@@ -148,6 +136,10 @@ export function ScheduleSheet() {
|
|
|
148
136
|
if (templatePrefill.intervalMs) setIntervalMs(templatePrefill.intervalMs)
|
|
149
137
|
setAgentId('')
|
|
150
138
|
setStatus('active')
|
|
139
|
+
setTaskMode('task')
|
|
140
|
+
setMessage('')
|
|
141
|
+
setTimezone('')
|
|
142
|
+
setStaggerSec(0)
|
|
151
143
|
setStep(1) // Skip template picker, go to "What" step
|
|
152
144
|
setTemplatePrefill(null)
|
|
153
145
|
} else {
|
|
@@ -162,26 +154,22 @@ export function ScheduleSheet() {
|
|
|
162
154
|
setTaskMode('task')
|
|
163
155
|
setMessage('')
|
|
164
156
|
setCustomCron(false)
|
|
157
|
+
setTimezone('')
|
|
158
|
+
setStaggerSec(0)
|
|
165
159
|
}
|
|
160
|
+
setTimingPreview(null)
|
|
161
|
+
setTimingPreviewError('')
|
|
162
|
+
setTimingPreviewLoading(false)
|
|
166
163
|
}
|
|
167
164
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
168
165
|
}, [open, editingId])
|
|
169
166
|
|
|
170
167
|
const cronHuman = useMemo(() => formatCronHuman(cron), [cron])
|
|
171
|
-
const [nextRuns, setNextRuns] = useState<Date[]>([])
|
|
172
|
-
useEffect(() => {
|
|
173
|
-
getNextRunsAsync(cron).then(setNextRuns)
|
|
174
|
-
}, [cron])
|
|
175
|
-
|
|
176
|
-
const onClose = () => {
|
|
177
|
-
setConfirmDelete(false)
|
|
178
|
-
setDeleting(false)
|
|
179
|
-
setOpen(false)
|
|
180
|
-
setEditingId(null)
|
|
181
|
-
}
|
|
182
168
|
|
|
183
|
-
const
|
|
184
|
-
const
|
|
169
|
+
const buildScheduleData = () => {
|
|
170
|
+
const trimmedTimezone = timezone.trim()
|
|
171
|
+
const normalizedStaggerSec = Math.max(0, Math.trunc(staggerSec || 0))
|
|
172
|
+
return {
|
|
185
173
|
name: name.trim(),
|
|
186
174
|
agentId,
|
|
187
175
|
taskPrompt: taskMode === 'wake_only' ? message : taskPrompt,
|
|
@@ -192,8 +180,67 @@ export function ScheduleSheet() {
|
|
|
192
180
|
cron: scheduleType === 'cron' ? cron : undefined,
|
|
193
181
|
intervalMs: scheduleType === 'interval' ? intervalMs : undefined,
|
|
194
182
|
runAt: scheduleType === 'once' ? Date.now() + intervalMs : undefined,
|
|
183
|
+
timezone: scheduleType === 'cron' && trimmedTimezone ? trimmedTimezone : undefined,
|
|
184
|
+
staggerSec: normalizedStaggerSec > 0 ? normalizedStaggerSec : undefined,
|
|
195
185
|
status,
|
|
196
186
|
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (!open || step === templateStep) {
|
|
191
|
+
setTimingPreview(null)
|
|
192
|
+
setTimingPreviewError('')
|
|
193
|
+
setTimingPreviewLoading(false)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const taskText = taskMode === 'wake_only' ? message.trim() : taskPrompt.trim()
|
|
198
|
+
const hasTiming = scheduleType === 'cron'
|
|
199
|
+
? cron.trim().length > 0
|
|
200
|
+
: intervalMs > 0
|
|
201
|
+
if (!agentId || !taskText || !hasTiming) {
|
|
202
|
+
setTimingPreview(null)
|
|
203
|
+
setTimingPreviewError('')
|
|
204
|
+
setTimingPreviewLoading(false)
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let cancelled = false
|
|
209
|
+
const timer = window.setTimeout(() => {
|
|
210
|
+
setTimingPreviewLoading(true)
|
|
211
|
+
setTimingPreviewError('')
|
|
212
|
+
previewSchedule(buildScheduleData() as Partial<Schedule>)
|
|
213
|
+
.then((result) => {
|
|
214
|
+
if (cancelled) return
|
|
215
|
+
setTimingPreview(result)
|
|
216
|
+
setTimingPreviewError(result.ok ? '' : result.error)
|
|
217
|
+
})
|
|
218
|
+
.catch((err: unknown) => {
|
|
219
|
+
if (cancelled) return
|
|
220
|
+
setTimingPreview(null)
|
|
221
|
+
setTimingPreviewError(err instanceof Error ? err.message : 'Failed to preview schedule')
|
|
222
|
+
})
|
|
223
|
+
.finally(() => {
|
|
224
|
+
if (!cancelled) setTimingPreviewLoading(false)
|
|
225
|
+
})
|
|
226
|
+
}, 300)
|
|
227
|
+
|
|
228
|
+
return () => {
|
|
229
|
+
cancelled = true
|
|
230
|
+
window.clearTimeout(timer)
|
|
231
|
+
}
|
|
232
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
233
|
+
}, [open, step, templateStep, name, agentId, taskPrompt, taskMode, message, scheduleType, cron, intervalMs, status, timezone, staggerSec])
|
|
234
|
+
|
|
235
|
+
const onClose = () => {
|
|
236
|
+
setConfirmDelete(false)
|
|
237
|
+
setDeleting(false)
|
|
238
|
+
setOpen(false)
|
|
239
|
+
setEditingId(null)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const handleSave = async () => {
|
|
243
|
+
const data = buildScheduleData()
|
|
197
244
|
try {
|
|
198
245
|
if (editing) {
|
|
199
246
|
await updateSchedule(editing.id, data)
|
|
@@ -227,10 +274,56 @@ export function ScheduleSheet() {
|
|
|
227
274
|
|
|
228
275
|
// Step validation
|
|
229
276
|
const step0Valid = name.trim().length > 0 && agentId.length > 0 && (taskMode === 'wake_only' ? message.trim().length > 0 : taskPrompt.trim().length > 0)
|
|
230
|
-
const step1Valid = scheduleType === 'cron' ? cron.trim().length > 0 : intervalMs > 0
|
|
277
|
+
const step1Valid = (scheduleType === 'cron' ? cron.trim().length > 0 : intervalMs > 0) && !timingPreviewError
|
|
231
278
|
|
|
232
279
|
const selectedAgent = agentId ? agents[agentId] : null
|
|
233
280
|
const creatorAgent = editing?.createdByAgentId ? agents[editing.createdByAgentId] : null
|
|
281
|
+
const previewOk = timingPreview && timingPreview.ok ? timingPreview : null
|
|
282
|
+
|
|
283
|
+
const timingPreviewPanel = (
|
|
284
|
+
<div className="p-4 rounded-[14px] bg-surface border border-white/[0.06]">
|
|
285
|
+
<div className="flex flex-wrap items-center gap-2 mb-2">
|
|
286
|
+
<div className="text-[14px] text-text-2 font-600">
|
|
287
|
+
{scheduleType === 'cron' ? cronHuman : previewOk?.cadence || (scheduleType === 'once' ? 'Run once' : `Every ${Math.round(intervalMs / 60000)} minutes`)}
|
|
288
|
+
</div>
|
|
289
|
+
{previewOk?.timezone && (
|
|
290
|
+
<span className="rounded-[999px] bg-white/[0.05] px-2 py-0.5 text-[11px] font-600 text-text-3">
|
|
291
|
+
{previewOk.timezone}
|
|
292
|
+
</span>
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
{scheduleType === 'cron' && cron && (
|
|
296
|
+
<div className="font-mono text-[12px] text-text-3/50 mb-3">{cron}</div>
|
|
297
|
+
)}
|
|
298
|
+
{timingPreviewLoading && (
|
|
299
|
+
<div className="text-[12px] text-text-3">Checking schedule...</div>
|
|
300
|
+
)}
|
|
301
|
+
{timingPreviewError && (
|
|
302
|
+
<div className="text-[12px] text-red-400">{timingPreviewError}</div>
|
|
303
|
+
)}
|
|
304
|
+
{previewOk && !timingPreviewLoading && (
|
|
305
|
+
<>
|
|
306
|
+
{previewOk.nextRuns.length > 0 ? (
|
|
307
|
+
<div className="space-y-1.5">
|
|
308
|
+
<div className="text-[11px] text-text-3/60 uppercase tracking-wider font-600">Next runs</div>
|
|
309
|
+
{previewOk.nextRuns.map((run) => (
|
|
310
|
+
<div key={run.iso} className="text-[12px] text-text-3 font-mono">{run.label}</div>
|
|
311
|
+
))}
|
|
312
|
+
</div>
|
|
313
|
+
) : (
|
|
314
|
+
<div className="text-[12px] text-text-3">No future runs calculated.</div>
|
|
315
|
+
)}
|
|
316
|
+
{previewOk.warnings.length > 0 && (
|
|
317
|
+
<div className="mt-3 space-y-1.5">
|
|
318
|
+
{previewOk.warnings.map((warning) => (
|
|
319
|
+
<div key={warning} className="text-[12px] text-amber-300">{warning}</div>
|
|
320
|
+
))}
|
|
321
|
+
</div>
|
|
322
|
+
)}
|
|
323
|
+
</>
|
|
324
|
+
)}
|
|
325
|
+
</div>
|
|
326
|
+
)
|
|
234
327
|
|
|
235
328
|
return (
|
|
236
329
|
<BottomSheet open={open} onClose={onClose} wide>
|
|
@@ -501,21 +594,32 @@ export function ScheduleSheet() {
|
|
|
501
594
|
<input type="text" value={cron} onChange={(e) => setCron(e.target.value)} placeholder="0 * * * *" className={`${inputClass} font-mono text-[14px] mb-3`} />
|
|
502
595
|
)}
|
|
503
596
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
{
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
597
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
|
|
598
|
+
<div>
|
|
599
|
+
<SectionLabel>Timezone</SectionLabel>
|
|
600
|
+
<input
|
|
601
|
+
type="text"
|
|
602
|
+
value={timezone}
|
|
603
|
+
onChange={(e) => setTimezone(e.target.value)}
|
|
604
|
+
placeholder="UTC or America/New_York"
|
|
605
|
+
className={inputClass}
|
|
606
|
+
style={{ fontFamily: 'inherit' }}
|
|
607
|
+
/>
|
|
608
|
+
</div>
|
|
609
|
+
<div>
|
|
610
|
+
<SectionLabel>Stagger (seconds)</SectionLabel>
|
|
611
|
+
<input
|
|
612
|
+
type="number"
|
|
613
|
+
min={0}
|
|
614
|
+
value={staggerSec}
|
|
615
|
+
onChange={(e) => setStaggerSec(Math.max(0, parseInt(e.target.value, 10) || 0))}
|
|
616
|
+
className={inputClass}
|
|
617
|
+
style={{ fontFamily: 'inherit' }}
|
|
618
|
+
/>
|
|
619
|
+
</div>
|
|
518
620
|
</div>
|
|
621
|
+
|
|
622
|
+
{timingPreviewPanel}
|
|
519
623
|
</div>
|
|
520
624
|
)}
|
|
521
625
|
|
|
@@ -529,6 +633,34 @@ export function ScheduleSheet() {
|
|
|
529
633
|
className={inputClass}
|
|
530
634
|
style={{ fontFamily: 'inherit' }}
|
|
531
635
|
/>
|
|
636
|
+
<div className="mt-3 mb-3">
|
|
637
|
+
<SectionLabel>Stagger (seconds)</SectionLabel>
|
|
638
|
+
<input
|
|
639
|
+
type="number"
|
|
640
|
+
min={0}
|
|
641
|
+
value={staggerSec}
|
|
642
|
+
onChange={(e) => setStaggerSec(Math.max(0, parseInt(e.target.value, 10) || 0))}
|
|
643
|
+
className={inputClass}
|
|
644
|
+
style={{ fontFamily: 'inherit' }}
|
|
645
|
+
/>
|
|
646
|
+
</div>
|
|
647
|
+
{timingPreviewPanel}
|
|
648
|
+
</div>
|
|
649
|
+
)}
|
|
650
|
+
|
|
651
|
+
{scheduleType === 'once' && (
|
|
652
|
+
<div className="mb-8">
|
|
653
|
+
<SectionLabel>Run After (minutes)</SectionLabel>
|
|
654
|
+
<input
|
|
655
|
+
type="number"
|
|
656
|
+
value={Math.round(intervalMs / 60000)}
|
|
657
|
+
onChange={(e) => setIntervalMs(Math.max(1, parseInt(e.target.value) || 1) * 60000)}
|
|
658
|
+
className={inputClass}
|
|
659
|
+
style={{ fontFamily: 'inherit' }}
|
|
660
|
+
/>
|
|
661
|
+
<div className="mt-3">
|
|
662
|
+
{timingPreviewPanel}
|
|
663
|
+
</div>
|
|
532
664
|
</div>
|
|
533
665
|
)}
|
|
534
666
|
|
|
@@ -620,6 +752,15 @@ export function ScheduleSheet() {
|
|
|
620
752
|
{scheduleType === 'once' && (
|
|
621
753
|
<div className="text-[12px] text-text-3 font-mono mt-0.5">Run once</div>
|
|
622
754
|
)}
|
|
755
|
+
{scheduleType === 'cron' && timezone.trim() && (
|
|
756
|
+
<div className="text-[12px] text-text-3 mt-1">Timezone: {timezone.trim()}</div>
|
|
757
|
+
)}
|
|
758
|
+
{staggerSec > 0 && (
|
|
759
|
+
<div className="text-[12px] text-text-3 mt-1">Stagger: up to {staggerSec} seconds</div>
|
|
760
|
+
)}
|
|
761
|
+
{previewOk?.nextRuns[0] && (
|
|
762
|
+
<div className="text-[12px] text-text-3 mt-1">Next: {previewOk.nextRuns[0].label}</div>
|
|
763
|
+
)}
|
|
623
764
|
</div>
|
|
624
765
|
{editing && (
|
|
625
766
|
<div>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { buildAgentConfigVersionSummary, formatAgentConfigVersionAge } from './agent-config-history'
|
|
5
|
+
import type { ConfigVersion } from '@/types/config-version'
|
|
6
|
+
|
|
7
|
+
function makeVersion(overrides: Partial<ConfigVersion> = {}): ConfigVersion {
|
|
8
|
+
return {
|
|
9
|
+
id: 'version_1',
|
|
10
|
+
entityKind: 'agent',
|
|
11
|
+
entityId: 'agent_1',
|
|
12
|
+
actor: 'user',
|
|
13
|
+
approvalId: null,
|
|
14
|
+
note: null,
|
|
15
|
+
createdAt: 1_700_000_000_000,
|
|
16
|
+
snapshot: {
|
|
17
|
+
name: 'Release Agent',
|
|
18
|
+
provider: 'openai',
|
|
19
|
+
model: 'gpt-5.2',
|
|
20
|
+
},
|
|
21
|
+
...overrides,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('formatAgentConfigVersionAge formats compact relative ages', () => {
|
|
26
|
+
assert.equal(formatAgentConfigVersionAge(1_700_000_000_000, 1_700_000_030_000), 'just now')
|
|
27
|
+
assert.equal(formatAgentConfigVersionAge(1_700_000_000_000, 1_700_000_300_000), '5m ago')
|
|
28
|
+
assert.equal(formatAgentConfigVersionAge(1_700_000_000_000, 1_700_010_800_000), '3h ago')
|
|
29
|
+
assert.equal(formatAgentConfigVersionAge(1_700_000_000_000, 1_700_259_200_000), '3d ago')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('buildAgentConfigVersionSummary surfaces useful agent snapshot labels', () => {
|
|
33
|
+
const summary = buildAgentConfigVersionSummary(makeVersion(), 1_700_000_300_000)
|
|
34
|
+
|
|
35
|
+
assert.equal(summary.id, 'version_1')
|
|
36
|
+
assert.equal(summary.title, 'Release Agent')
|
|
37
|
+
assert.equal(summary.subtitle, 'openai / gpt-5.2')
|
|
38
|
+
assert.equal(summary.meta, '5m ago by user')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('buildAgentConfigVersionSummary handles sparse snapshots', () => {
|
|
42
|
+
const summary = buildAgentConfigVersionSummary(makeVersion({
|
|
43
|
+
actor: 'system',
|
|
44
|
+
snapshot: {},
|
|
45
|
+
}), 1_700_000_300_000)
|
|
46
|
+
|
|
47
|
+
assert.equal(summary.title, 'Previous agent settings')
|
|
48
|
+
assert.equal(summary.subtitle, 'No provider snapshot')
|
|
49
|
+
assert.equal(summary.meta, '5m ago by system')
|
|
50
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ConfigVersion } from '@/types/config-version'
|
|
2
|
+
|
|
3
|
+
export interface AgentConfigVersionSummary {
|
|
4
|
+
id: string
|
|
5
|
+
title: string
|
|
6
|
+
subtitle: string
|
|
7
|
+
meta: string
|
|
8
|
+
createdAt: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function snapshotString(snapshot: Record<string, unknown>, key: string): string {
|
|
12
|
+
const value = snapshot[key]
|
|
13
|
+
return typeof value === 'string' && value.trim() ? value.trim() : ''
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatAgentConfigVersionAge(createdAt: number, now = Date.now()): string {
|
|
17
|
+
const ageMs = Math.max(0, now - createdAt)
|
|
18
|
+
const seconds = Math.floor(ageMs / 1000)
|
|
19
|
+
if (seconds < 60) return 'just now'
|
|
20
|
+
const minutes = Math.floor(seconds / 60)
|
|
21
|
+
if (minutes < 60) return `${minutes}m ago`
|
|
22
|
+
const hours = Math.floor(minutes / 60)
|
|
23
|
+
if (hours < 24) return `${hours}h ago`
|
|
24
|
+
const days = Math.floor(hours / 24)
|
|
25
|
+
return `${days}d ago`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildAgentConfigVersionSummary(
|
|
29
|
+
version: ConfigVersion,
|
|
30
|
+
now = Date.now(),
|
|
31
|
+
): AgentConfigVersionSummary {
|
|
32
|
+
const snapshot = version.snapshot || {}
|
|
33
|
+
const name = snapshotString(snapshot, 'name')
|
|
34
|
+
const provider = snapshotString(snapshot, 'provider')
|
|
35
|
+
const model = snapshotString(snapshot, 'model')
|
|
36
|
+
const title = name || 'Previous agent settings'
|
|
37
|
+
const subtitle = provider && model
|
|
38
|
+
? `${provider} / ${model}`
|
|
39
|
+
: provider || model || 'No provider snapshot'
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
id: version.id,
|
|
43
|
+
title,
|
|
44
|
+
subtitle,
|
|
45
|
+
meta: `${formatAgentConfigVersionAge(version.createdAt, now)} by ${version.actor || 'user'}`,
|
|
46
|
+
createdAt: version.createdAt,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { api } from '@/lib/app/api-client'
|
|
2
|
-
import type { Schedule, ScheduleHistoryEntry } from '@/types'
|
|
2
|
+
import type { Schedule, ScheduleHistoryEntry, SchedulePreviewResponse } from '@/types'
|
|
3
3
|
|
|
4
4
|
export interface ScheduleArchiveResponse {
|
|
5
5
|
ok: boolean
|
|
@@ -31,6 +31,9 @@ export const fetchSchedules = (includeArchived = false) =>
|
|
|
31
31
|
export const createSchedule = (data: Omit<Schedule, 'id' | 'createdAt' | 'lastRunAt' | 'nextRunAt'>) =>
|
|
32
32
|
api<Schedule>('POST', '/schedules', data)
|
|
33
33
|
|
|
34
|
+
export const previewSchedule = (data: Partial<Schedule>) =>
|
|
35
|
+
api<SchedulePreviewResponse>('POST', '/schedules/preview', data, { timeoutMs: 8_000 })
|
|
36
|
+
|
|
34
37
|
export const updateSchedule = (id: string, data: Partial<Schedule>) =>
|
|
35
38
|
api<Schedule>('PUT', `/schedules/${id}`, data)
|
|
36
39
|
|
|
@@ -51,6 +51,15 @@ function normalizeScheduleTimestamp(value: unknown): number | null {
|
|
|
51
51
|
return Math.trunc(parsedTime)
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
function isValidTimeZone(value: string): boolean {
|
|
55
|
+
try {
|
|
56
|
+
new Intl.DateTimeFormat('en-US', { timeZone: value })
|
|
57
|
+
return true
|
|
58
|
+
} catch {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
54
63
|
/**
|
|
55
64
|
* Parse natural "at HH:MM" time expressions into a cron string.
|
|
56
65
|
* Supports: "at 09:00", "at 14:30", "at 9am", "at 2:30pm", "daily at 09:00"
|
|
@@ -226,7 +235,14 @@ export function normalizeSchedulePayload(payload: SchedulePayload, opts: Normali
|
|
|
226
235
|
|
|
227
236
|
// Preserve timezone and stagger
|
|
228
237
|
const timezone = trimString(normalized.timezone)
|
|
229
|
-
if (timezone)
|
|
238
|
+
if (timezone) {
|
|
239
|
+
if (!isValidTimeZone(timezone)) {
|
|
240
|
+
return { ok: false, error: `Error: invalid timezone: ${timezone}.` }
|
|
241
|
+
}
|
|
242
|
+
normalized.timezone = timezone
|
|
243
|
+
} else if (normalized.timezone != null) {
|
|
244
|
+
delete normalized.timezone
|
|
245
|
+
}
|
|
230
246
|
const staggerSec = normalizePositiveInt(normalized.staggerSec)
|
|
231
247
|
if (staggerSec != null) normalized.staggerSec = staggerSec
|
|
232
248
|
else if (normalized.staggerSec != null) delete normalized.staggerSec
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { previewSchedulePayload } from './schedule-preview'
|
|
5
|
+
|
|
6
|
+
const NOW = Date.parse('2026-05-06T12:00:00.000Z')
|
|
7
|
+
|
|
8
|
+
function basePayload(overrides: Record<string, unknown> = {}) {
|
|
9
|
+
return {
|
|
10
|
+
agentId: 'agent-1',
|
|
11
|
+
name: 'Preview schedule',
|
|
12
|
+
taskPrompt: 'Review the queue',
|
|
13
|
+
status: 'active',
|
|
14
|
+
...overrides,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('previewSchedulePayload', () => {
|
|
19
|
+
it('forecasts cron schedules with an explicit timezone', () => {
|
|
20
|
+
const preview = previewSchedulePayload(basePayload({
|
|
21
|
+
scheduleType: 'cron',
|
|
22
|
+
cron: '0 9 * * *',
|
|
23
|
+
timezone: 'UTC',
|
|
24
|
+
}), { now: NOW })
|
|
25
|
+
|
|
26
|
+
assert.equal(preview.ok, true)
|
|
27
|
+
if (!preview.ok) return
|
|
28
|
+
assert.equal(preview.scheduleType, 'cron')
|
|
29
|
+
assert.equal(preview.timezone, 'UTC')
|
|
30
|
+
assert.equal(preview.nextRuns.length, 5)
|
|
31
|
+
assert.equal(preview.nextRunAt, Date.parse('2026-05-07T09:00:00.000Z'))
|
|
32
|
+
assert.equal(preview.nextRuns[0].iso, '2026-05-07T09:00:00.000Z')
|
|
33
|
+
assert.deepEqual(preview.warnings, [])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('warns when cron schedules rely on host local time', () => {
|
|
37
|
+
const preview = previewSchedulePayload(basePayload({
|
|
38
|
+
scheduleType: 'cron',
|
|
39
|
+
cron: '0 9 * * *',
|
|
40
|
+
}), { now: NOW, count: 2 })
|
|
41
|
+
|
|
42
|
+
assert.equal(preview.ok, true)
|
|
43
|
+
if (!preview.ok) return
|
|
44
|
+
assert.equal(preview.nextRuns.length, 2)
|
|
45
|
+
assert.match(preview.warnings.join('\n'), /host local timezone/)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('rejects invalid timezone values before saving', () => {
|
|
49
|
+
const preview = previewSchedulePayload(basePayload({
|
|
50
|
+
scheduleType: 'cron',
|
|
51
|
+
cron: '0 9 * * *',
|
|
52
|
+
timezone: 'Not/AZone',
|
|
53
|
+
}), { now: NOW })
|
|
54
|
+
|
|
55
|
+
assert.equal(preview.ok, false)
|
|
56
|
+
if (preview.ok) return
|
|
57
|
+
assert.match(preview.error, /invalid timezone/)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('forecasts interval schedules from the preview time', () => {
|
|
61
|
+
const preview = previewSchedulePayload(basePayload({
|
|
62
|
+
scheduleType: 'interval',
|
|
63
|
+
intervalMs: 30 * 60_000,
|
|
64
|
+
}), { now: NOW, count: 3 })
|
|
65
|
+
|
|
66
|
+
assert.equal(preview.ok, true)
|
|
67
|
+
if (!preview.ok) return
|
|
68
|
+
assert.equal(preview.scheduleType, 'interval')
|
|
69
|
+
assert.equal(preview.nextRuns.length, 3)
|
|
70
|
+
assert.deepEqual(preview.nextRuns.map((run) => run.at), [
|
|
71
|
+
NOW + 30 * 60_000,
|
|
72
|
+
NOW + 60 * 60_000,
|
|
73
|
+
NOW + 90 * 60_000,
|
|
74
|
+
])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('warns when a run-once schedule is already in the past', () => {
|
|
78
|
+
const preview = previewSchedulePayload(basePayload({
|
|
79
|
+
scheduleType: 'once',
|
|
80
|
+
runAt: NOW - 60_000,
|
|
81
|
+
}), { now: NOW })
|
|
82
|
+
|
|
83
|
+
assert.equal(preview.ok, true)
|
|
84
|
+
if (!preview.ok) return
|
|
85
|
+
assert.equal(preview.nextRunAt, null)
|
|
86
|
+
assert.equal(preview.nextRuns.length, 0)
|
|
87
|
+
assert.match(preview.warnings.join('\n'), /past/)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { CronExpressionParser } from 'cron-parser'
|
|
2
|
+
|
|
3
|
+
import { normalizeSchedulePayload } from '@/lib/server/schedules/schedule-normalization'
|
|
4
|
+
import type { SchedulePreviewResponse, SchedulePreviewRun, ScheduleType } from '@/types'
|
|
5
|
+
|
|
6
|
+
type SchedulePayload = Record<string, unknown>
|
|
7
|
+
|
|
8
|
+
export interface PreviewScheduleOptions {
|
|
9
|
+
cwd?: string | null
|
|
10
|
+
now?: number
|
|
11
|
+
count?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_PREVIEW_RUNS = 5
|
|
15
|
+
|
|
16
|
+
function trimString(value: unknown): string {
|
|
17
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function positiveNumber(value: unknown): number | null {
|
|
21
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null
|
|
22
|
+
return Math.trunc(value)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatDuration(ms: number): string {
|
|
26
|
+
const minutes = Math.round(ms / 60_000)
|
|
27
|
+
if (minutes <= 1) return 'Every minute'
|
|
28
|
+
if (minutes < 60) return `Every ${minutes} minutes`
|
|
29
|
+
const hours = Math.round(minutes / 60)
|
|
30
|
+
if (hours < 24) return hours === 1 ? 'Every hour' : `Every ${hours} hours`
|
|
31
|
+
const days = Math.round(hours / 24)
|
|
32
|
+
return days === 1 ? 'Every day' : `Every ${days} days`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatRunLabel(timestamp: number, timezone: string | null): string {
|
|
36
|
+
const date = new Date(timestamp)
|
|
37
|
+
try {
|
|
38
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
39
|
+
weekday: 'short',
|
|
40
|
+
month: 'short',
|
|
41
|
+
day: 'numeric',
|
|
42
|
+
hour: '2-digit',
|
|
43
|
+
minute: '2-digit',
|
|
44
|
+
...(timezone ? { timeZone: timezone, timeZoneName: 'short' as const } : {}),
|
|
45
|
+
}).format(date)
|
|
46
|
+
} catch {
|
|
47
|
+
return date.toISOString()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildPreviewRun(timestamp: number, timezone: string | null): SchedulePreviewRun {
|
|
52
|
+
return {
|
|
53
|
+
at: timestamp,
|
|
54
|
+
iso: new Date(timestamp).toISOString(),
|
|
55
|
+
label: formatRunLabel(timestamp, timezone),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildCronRuns(cron: string, timezone: string | null, now: number, count: number): SchedulePreviewRun[] {
|
|
60
|
+
const interval = CronExpressionParser.parse(cron, {
|
|
61
|
+
currentDate: new Date(now),
|
|
62
|
+
...(timezone ? { tz: timezone } : {}),
|
|
63
|
+
})
|
|
64
|
+
const runs: SchedulePreviewRun[] = []
|
|
65
|
+
for (let index = 0; index < count; index += 1) {
|
|
66
|
+
runs.push(buildPreviewRun(interval.next().getTime(), timezone))
|
|
67
|
+
}
|
|
68
|
+
return runs
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildIntervalRuns(intervalMs: number, timezone: string | null, now: number, count: number): SchedulePreviewRun[] {
|
|
72
|
+
const runs: SchedulePreviewRun[] = []
|
|
73
|
+
for (let index = 1; index <= count; index += 1) {
|
|
74
|
+
runs.push(buildPreviewRun(now + (intervalMs * index), timezone))
|
|
75
|
+
}
|
|
76
|
+
return runs
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cadenceLabel(scheduleType: ScheduleType, payload: SchedulePayload): string {
|
|
80
|
+
if (scheduleType === 'cron') {
|
|
81
|
+
const cron = trimString(payload.cron)
|
|
82
|
+
return cron ? `Cron ${cron}` : 'Cron'
|
|
83
|
+
}
|
|
84
|
+
if (scheduleType === 'interval') {
|
|
85
|
+
const intervalMs = positiveNumber(payload.intervalMs)
|
|
86
|
+
return intervalMs ? formatDuration(intervalMs) : 'Interval'
|
|
87
|
+
}
|
|
88
|
+
return 'Run once'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function previewSchedulePayload(
|
|
92
|
+
payload: SchedulePayload,
|
|
93
|
+
options: PreviewScheduleOptions = {},
|
|
94
|
+
): SchedulePreviewResponse {
|
|
95
|
+
const now = typeof options.now === 'number' ? options.now : Date.now()
|
|
96
|
+
const count = Math.max(1, Math.min(10, Math.trunc(options.count ?? DEFAULT_PREVIEW_RUNS)))
|
|
97
|
+
const normalized = normalizeSchedulePayload({
|
|
98
|
+
...payload,
|
|
99
|
+
nextRunAt: undefined,
|
|
100
|
+
}, {
|
|
101
|
+
cwd: options.cwd,
|
|
102
|
+
now,
|
|
103
|
+
})
|
|
104
|
+
if (!normalized.ok) {
|
|
105
|
+
return { ok: false, error: normalized.error }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const value = normalized.value
|
|
109
|
+
const scheduleType = value.scheduleType === 'cron' || value.scheduleType === 'once'
|
|
110
|
+
? value.scheduleType
|
|
111
|
+
: 'interval'
|
|
112
|
+
const timezone = trimString(value.timezone) || null
|
|
113
|
+
const warnings: string[] = []
|
|
114
|
+
const staggerSec = positiveNumber(value.staggerSec)
|
|
115
|
+
|
|
116
|
+
if (scheduleType === 'cron' && !timezone) {
|
|
117
|
+
warnings.push('Timezone is not set, so cron runs use the host local timezone.')
|
|
118
|
+
}
|
|
119
|
+
if (staggerSec) {
|
|
120
|
+
warnings.push(`Stagger may delay each run by up to ${staggerSec} seconds.`)
|
|
121
|
+
}
|
|
122
|
+
if (value.status === 'paused') {
|
|
123
|
+
warnings.push('Paused schedules keep their forecast but will not run until reactivated.')
|
|
124
|
+
}
|
|
125
|
+
if (value.status === 'archived') {
|
|
126
|
+
warnings.push('Archived schedules do not run until restored.')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let nextRuns: SchedulePreviewRun[] = []
|
|
130
|
+
try {
|
|
131
|
+
if (scheduleType === 'cron') {
|
|
132
|
+
const cron = trimString(value.cron)
|
|
133
|
+
if (!cron) {
|
|
134
|
+
return { ok: false, error: 'Error: cron schedules require a cron expression.' }
|
|
135
|
+
}
|
|
136
|
+
nextRuns = buildCronRuns(cron, timezone, now, count)
|
|
137
|
+
} else if (scheduleType === 'interval') {
|
|
138
|
+
const intervalMs = positiveNumber(value.intervalMs)
|
|
139
|
+
if (!intervalMs) {
|
|
140
|
+
return { ok: false, error: 'Error: interval schedules require intervalMs.' }
|
|
141
|
+
}
|
|
142
|
+
nextRuns = buildIntervalRuns(intervalMs, timezone, now, count)
|
|
143
|
+
} else {
|
|
144
|
+
const runAt = positiveNumber(value.runAt)
|
|
145
|
+
if (!runAt) {
|
|
146
|
+
warnings.push('Run-once schedules need a runAt timestamp before they can be queued.')
|
|
147
|
+
} else if (runAt <= now) {
|
|
148
|
+
warnings.push('Run-once timestamp is in the past.')
|
|
149
|
+
} else {
|
|
150
|
+
nextRuns = [buildPreviewRun(runAt, timezone)]
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
return { ok: false, error: 'Error: invalid schedule timing.' }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (nextRuns.length === 0 && value.status !== 'archived') {
|
|
158
|
+
warnings.push('No future runs could be calculated from this schedule.')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
scheduleType,
|
|
164
|
+
cadence: cadenceLabel(scheduleType, value),
|
|
165
|
+
timezone,
|
|
166
|
+
nextRunAt: nextRuns[0]?.at ?? null,
|
|
167
|
+
nextRuns,
|
|
168
|
+
warnings,
|
|
169
|
+
normalized: value,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -19,7 +19,8 @@ import { pushMainLoopEventToMainSessions } from '@/lib/server/agents/main-agent-
|
|
|
19
19
|
import { WORKSPACE_DIR } from '@/lib/server/data-dir'
|
|
20
20
|
import { notify } from '@/lib/server/ws-hub'
|
|
21
21
|
import { appendScheduleHistoryEntry } from '@/lib/server/schedules/schedule-history'
|
|
22
|
-
import
|
|
22
|
+
import { previewSchedulePayload } from '@/lib/server/schedules/schedule-preview'
|
|
23
|
+
import type { Schedule, SchedulePreviewResponse } from '@/types'
|
|
23
24
|
import type { ScheduleLike } from '@/lib/schedules/schedule-dedupe'
|
|
24
25
|
import type { ServiceResult } from '@/lib/server/service-result'
|
|
25
26
|
|
|
@@ -39,6 +40,29 @@ export function listSchedulesForApi(includeArchived: boolean) {
|
|
|
39
40
|
return filtered
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
export function previewScheduleFromRoute(body: Record<string, unknown>): ServiceResult<SchedulePreviewResponse> {
|
|
44
|
+
const result = previewSchedulePayload(body, {
|
|
45
|
+
cwd: WORKSPACE_DIR,
|
|
46
|
+
now: Date.now(),
|
|
47
|
+
})
|
|
48
|
+
if (!result.ok) return serviceFail(400, result.error)
|
|
49
|
+
|
|
50
|
+
const agents = loadAgents()
|
|
51
|
+
const agentId = typeof result.normalized.agentId === 'string' ? result.normalized.agentId : ''
|
|
52
|
+
const agent = agentId ? agents[agentId] : null
|
|
53
|
+
const warnings = [...result.warnings]
|
|
54
|
+
if (agentId && !agent) {
|
|
55
|
+
warnings.push(`Agent not found: ${agentId}`)
|
|
56
|
+
} else if (agent && isAgentDisabled(agent)) {
|
|
57
|
+
warnings.push(buildAgentDisabledMessage(agent, 'take scheduled work'))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return serviceOk({
|
|
61
|
+
...result,
|
|
62
|
+
warnings,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
42
66
|
export function createScheduleFromRoute(body: Record<string, unknown>): ServiceResult<ScheduleLike> {
|
|
43
67
|
const now = Date.now()
|
|
44
68
|
const schedules = loadSchedules()
|
package/src/types/schedule.ts
CHANGED
|
@@ -82,3 +82,26 @@ export interface Schedule {
|
|
|
82
82
|
createdAt: number
|
|
83
83
|
updatedAt?: number
|
|
84
84
|
}
|
|
85
|
+
|
|
86
|
+
export interface SchedulePreviewRun {
|
|
87
|
+
at: number
|
|
88
|
+
iso: string
|
|
89
|
+
label: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type SchedulePreviewResponse =
|
|
93
|
+
| {
|
|
94
|
+
ok: true
|
|
95
|
+
scheduleType: ScheduleType
|
|
96
|
+
cadence: string
|
|
97
|
+
timezone: string | null
|
|
98
|
+
nextRunAt: number | null
|
|
99
|
+
nextRuns: SchedulePreviewRun[]
|
|
100
|
+
warnings: string[]
|
|
101
|
+
normalized: Partial<Schedule> & Record<string, unknown>
|
|
102
|
+
}
|
|
103
|
+
| {
|
|
104
|
+
ok: false
|
|
105
|
+
error: string
|
|
106
|
+
warnings?: string[]
|
|
107
|
+
}
|