typeclaw 0.32.1 → 0.34.0
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/auth.schema.json +66 -0
- package/cron.schema.json +26 -2
- package/package.json +1 -1
- package/secrets.schema.json +66 -0
- package/src/agent/index.ts +7 -3
- package/src/agent/session-origin.ts +17 -0
- package/src/agent/subagent-completion-reminder.ts +14 -1
- package/src/agent/subagent-drain.ts +2 -0
- package/src/agent/subagents.ts +21 -7
- package/src/agent/tools/channel-disengage.ts +66 -0
- package/src/agent/tools/channel-log.ts +3 -2
- package/src/agent/tools/spawn-subagent.ts +25 -5
- package/src/agent/tools/subagent-output.ts +13 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
- package/src/bundled-plugins/memory/memory-logger.ts +7 -0
- package/src/bundled-plugins/researcher/researcher.ts +14 -11
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +2 -0
- package/src/channels/adapters/line-channel-resolver.ts +129 -0
- package/src/channels/adapters/line-classify.ts +80 -0
- package/src/channels/adapters/line-format.ts +11 -0
- package/src/channels/adapters/line.ts +350 -0
- package/src/channels/engagement.ts +4 -2
- package/src/channels/manager.ts +65 -6
- package/src/channels/router.ts +186 -41
- package/src/channels/schema.ts +6 -1
- package/src/cli/channel.ts +112 -1
- package/src/cli/cron.ts +22 -4
- package/src/cli/init.ts +267 -82
- package/src/cli/model.ts +5 -1
- package/src/cli/oauth-callbacks.ts +5 -4
- package/src/cli/provider.ts +41 -10
- package/src/config/providers.ts +366 -7
- package/src/cron/consumer.ts +33 -0
- package/src/cron/count-state.ts +208 -0
- package/src/cron/index.ts +4 -17
- package/src/cron/list.ts +24 -6
- package/src/cron/scheduler.ts +84 -9
- package/src/cron/schema.ts +100 -13
- package/src/doctor/channel-checks.ts +28 -0
- package/src/hostd/daemon.ts +14 -6
- package/src/hostd/protocol.ts +6 -2
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +36 -3
- package/src/init/line-auth.ts +98 -0
- package/src/init/models-dev.ts +3 -0
- package/src/init/run-owner-claim.ts +1 -0
- package/src/init/validate-api-key.ts +15 -0
- package/src/inspect/label.ts +1 -0
- package/src/permissions/match-rule.ts +28 -12
- package/src/permissions/resolve.ts +8 -1
- package/src/role-claim/match-rule.ts +5 -1
- package/src/run/index.ts +41 -4
- package/src/secrets/line-store.ts +112 -0
- package/src/secrets/oauth-xai.ts +342 -0
- package/src/secrets/schema.ts +25 -0
- package/src/secrets/storage.ts +2 -0
- package/src/server/index.ts +17 -4
- package/src/shared/protocol.ts +4 -1
- package/src/skills/typeclaw-channel-line/SKILL.md +46 -0
- package/src/skills/typeclaw-channels/SKILL.md +153 -0
- package/src/skills/typeclaw-config/SKILL.md +54 -184
- package/src/skills/typeclaw-config/references/dockerfile.md +66 -0
- package/src/skills/typeclaw-cron/SKILL.md +68 -14
- package/src/skills/typeclaw-permissions/SKILL.md +3 -3
- package/typeclaw.schema.json +185 -3
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import type { CronJob } from './schema'
|
|
6
|
+
|
|
7
|
+
export const CRON_STATE_FILE = join('cron', 'state.json')
|
|
8
|
+
|
|
9
|
+
type StateEntry = {
|
|
10
|
+
progressFingerprint: string
|
|
11
|
+
firedCount: number
|
|
12
|
+
lastAcceptedAt: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type StateFile = {
|
|
16
|
+
version: 1
|
|
17
|
+
jobs: Record<string, StateEntry>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type CountStore = {
|
|
21
|
+
// Fingerprint-aware: returns 0 unless the stored entry's recurrence
|
|
22
|
+
// fingerprint matches `job`. A stale fire from a previous job definition (or
|
|
23
|
+
// a resurrected entry after reload) therefore can't gate the live job.
|
|
24
|
+
get: (id: string, job: CronJob) => number
|
|
25
|
+
// Resolves `true` when the fire was accepted and counted, `false` when the
|
|
26
|
+
// job is no longer in the live set (removed/replaced while this was queued).
|
|
27
|
+
// Callers use the result to skip dispatching a stale job, not just to avoid
|
|
28
|
+
// miscounting it. The verdict is computed inside the write mutex, so it
|
|
29
|
+
// reflects any reconcile that landed before the write ran.
|
|
30
|
+
increment: (id: string, job: CronJob, at: number) => Promise<boolean>
|
|
31
|
+
// Re-applies boot-time reconciliation against a new job set (called on
|
|
32
|
+
// `typeclaw reload`) so re-added/changed jobs don't inherit stale counts.
|
|
33
|
+
reconcile: (jobs: CronJob[]) => Promise<void>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type CountStoreIO = {
|
|
37
|
+
read: (path: string) => Promise<string | null>
|
|
38
|
+
write: (path: string, data: string) => Promise<void>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const realIO: CountStoreIO = {
|
|
42
|
+
read: async (path) => (existsSync(path) ? readFile(path, 'utf8') : null),
|
|
43
|
+
// Temp-file + rename keeps readers from ever seeing a half-written file.
|
|
44
|
+
write: async (path, data) => {
|
|
45
|
+
await mkdir(dirname(path), { recursive: true })
|
|
46
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
|
|
47
|
+
await writeFile(tmp, data, 'utf8')
|
|
48
|
+
await rename(tmp, path)
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// `progressFingerprint` identifies the job's RECURRENCE IDENTITY, deliberately
|
|
53
|
+
// excluding the mutable limits (`count`, `until`) and `enabled`. Two jobs with
|
|
54
|
+
// the same id but a changed schedule/target are different recurrences, so the
|
|
55
|
+
// fire counter resets. Bumping only `count` (3 → 5) leaves the fingerprint
|
|
56
|
+
// unchanged, so progress is preserved and the job resumes firing.
|
|
57
|
+
export function progressFingerprint(job: CronJob): string {
|
|
58
|
+
return JSON.stringify({
|
|
59
|
+
id: job.id,
|
|
60
|
+
schedule: job.schedule ?? null,
|
|
61
|
+
at: job.at ?? null,
|
|
62
|
+
timezone: job.timezone ?? null,
|
|
63
|
+
kind: job.kind,
|
|
64
|
+
target: targetIdentity(job),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function targetIdentity(job: CronJob): unknown {
|
|
69
|
+
if (job.kind === 'prompt') return { prompt: job.prompt, subagent: job.subagent ?? null, payload: job.payload ?? null }
|
|
70
|
+
if (job.kind === 'exec') return job.command
|
|
71
|
+
return { handler: String(job.handler) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function matchingCount(entry: StateEntry | undefined, job: CronJob): number {
|
|
75
|
+
if (entry === undefined) return 0
|
|
76
|
+
return entry.progressFingerprint === progressFingerprint(job) ? entry.firedCount : 0
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function serialize(state: StateFile): string {
|
|
80
|
+
return JSON.stringify(state)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function activeFingerprints(jobs: CronJob[]): Map<string, string> {
|
|
84
|
+
const map = new Map<string, string>()
|
|
85
|
+
for (const job of jobs) {
|
|
86
|
+
if (job.count !== undefined) map.set(job.id, progressFingerprint(job))
|
|
87
|
+
}
|
|
88
|
+
return map
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function createCountStore(
|
|
92
|
+
agentDir: string,
|
|
93
|
+
jobs: CronJob[],
|
|
94
|
+
io: CountStoreIO = realIO,
|
|
95
|
+
): Promise<CountStore> {
|
|
96
|
+
const path = join(agentDir, CRON_STATE_FILE)
|
|
97
|
+
const onDisk = await readState(path, io)
|
|
98
|
+
const state: StateFile = reconcile(onDisk, jobs)
|
|
99
|
+
// Serializes writes so concurrent increments (two jobs firing in the same
|
|
100
|
+
// tick) can't clobber each other via read-modify-write races.
|
|
101
|
+
let tail: Promise<void> = Promise.resolve()
|
|
102
|
+
// Guards against a straggler fire that lost a reconcile race re-adding a
|
|
103
|
+
// tombstone for a removed job: an increment whose id/fingerprint is not in
|
|
104
|
+
// the current live set is dropped. Maps each live id to its fingerprint.
|
|
105
|
+
let active = activeFingerprints(jobs)
|
|
106
|
+
|
|
107
|
+
// Only touch disk when reconciliation actually pruned/reset something.
|
|
108
|
+
// Avoids a force-committed no-op rewrite of cron/state.json on every boot.
|
|
109
|
+
if (serialize(state) !== serialize(onDisk)) {
|
|
110
|
+
await persist(path, state, io)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
get: (id, job) => matchingCount(state.jobs[id], job),
|
|
115
|
+
increment: (id, job, at) => {
|
|
116
|
+
const fp = progressFingerprint(job)
|
|
117
|
+
const run = tail.then(async () => {
|
|
118
|
+
// Re-check INSIDE the tail body, not just before queueing: a reconcile
|
|
119
|
+
// can land synchronously between the queue and this body running, so a
|
|
120
|
+
// sync-only guard would still let a straggler write a tombstone for a
|
|
121
|
+
// job that's since been removed. `active` reflects the latest reconcile.
|
|
122
|
+
if (active.get(id) !== fp) return false
|
|
123
|
+
const prev = matchingCount(state.jobs[id], job)
|
|
124
|
+
state.jobs[id] = {
|
|
125
|
+
progressFingerprint: fp,
|
|
126
|
+
firedCount: prev + 1,
|
|
127
|
+
lastAcceptedAt: new Date(at).toISOString(),
|
|
128
|
+
}
|
|
129
|
+
await persist(path, state, io)
|
|
130
|
+
return true
|
|
131
|
+
})
|
|
132
|
+
tail = run.then(
|
|
133
|
+
() => {},
|
|
134
|
+
() => {},
|
|
135
|
+
)
|
|
136
|
+
return run
|
|
137
|
+
},
|
|
138
|
+
reconcile: (nextJobs) => {
|
|
139
|
+
// In-memory map is authoritative for `get`, so it must settle before the
|
|
140
|
+
// caller arms timers; only the on-disk persist trails behind the mutex.
|
|
141
|
+
active = activeFingerprints(nextJobs)
|
|
142
|
+
state.jobs = reconcile(state, nextJobs).jobs
|
|
143
|
+
const run = tail.then(async () => {
|
|
144
|
+
await persist(path, state, io)
|
|
145
|
+
})
|
|
146
|
+
tail = run.catch(() => {})
|
|
147
|
+
return run
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function emptyState(): StateFile {
|
|
153
|
+
return { version: 1, jobs: {} }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function readState(path: string, io: CountStoreIO): Promise<StateFile> {
|
|
157
|
+
const raw = await io.read(path)
|
|
158
|
+
if (raw === null) return emptyState()
|
|
159
|
+
try {
|
|
160
|
+
return validateState(JSON.parse(raw))
|
|
161
|
+
} catch {
|
|
162
|
+
return emptyState()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Durable on-disk state is untrusted: a corrupt or hand-edited file must never
|
|
167
|
+
// crash the scheduler or feed a bogus count into expiry. Drop the whole file
|
|
168
|
+
// on a version mismatch, and skip individual entries that aren't well-formed.
|
|
169
|
+
function validateState(raw: unknown): StateFile {
|
|
170
|
+
if (typeof raw !== 'object' || raw === null) return emptyState()
|
|
171
|
+
const obj = raw as { version?: unknown; jobs?: unknown }
|
|
172
|
+
if (obj.version !== 1 || typeof obj.jobs !== 'object' || obj.jobs === null) return emptyState()
|
|
173
|
+
|
|
174
|
+
const jobs: Record<string, StateEntry> = {}
|
|
175
|
+
for (const [id, value] of Object.entries(obj.jobs as Record<string, unknown>)) {
|
|
176
|
+
if (typeof value !== 'object' || value === null) continue
|
|
177
|
+
const e = value as Record<string, unknown>
|
|
178
|
+
if (typeof e.progressFingerprint !== 'string') continue
|
|
179
|
+
if (typeof e.firedCount !== 'number' || !Number.isInteger(e.firedCount) || e.firedCount < 0) continue
|
|
180
|
+
if (typeof e.lastAcceptedAt !== 'string') continue
|
|
181
|
+
jobs[id] = {
|
|
182
|
+
progressFingerprint: e.progressFingerprint,
|
|
183
|
+
firedCount: e.firedCount,
|
|
184
|
+
lastAcceptedAt: e.lastAcceptedAt,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { version: 1, jobs }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Boot/reload reconciliation. The scary footgun is a job id removed and later
|
|
191
|
+
// re-added with the SAME id: without this, the re-added job would inherit the
|
|
192
|
+
// old counter and never fire. We drop entries for ids that are gone or no
|
|
193
|
+
// longer counted, and reset entries whose recurrence fingerprint changed.
|
|
194
|
+
export function reconcile(state: StateFile, jobs: CronJob[]): StateFile {
|
|
195
|
+
const byId = new Map(jobs.map((j) => [j.id, j]))
|
|
196
|
+
const next: Record<string, StateEntry> = {}
|
|
197
|
+
for (const [id, entry] of Object.entries(state.jobs)) {
|
|
198
|
+
const job = byId.get(id)
|
|
199
|
+
if (!job || job.count === undefined) continue
|
|
200
|
+
if (entry.progressFingerprint !== progressFingerprint(job)) continue
|
|
201
|
+
next[id] = entry
|
|
202
|
+
}
|
|
203
|
+
return { version: 1, jobs: next }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function persist(path: string, state: StateFile, io: CountStoreIO): Promise<void> {
|
|
207
|
+
await io.write(path, JSON.stringify(state, null, 2))
|
|
208
|
+
}
|
package/src/cron/index.ts
CHANGED
|
@@ -6,22 +6,10 @@ import type { SubagentRegistry } from '@/agent/subagents'
|
|
|
6
6
|
|
|
7
7
|
import { type CronFile, parseCronFile } from './schema'
|
|
8
8
|
|
|
9
|
-
export {
|
|
10
|
-
export {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
type CronConsumer,
|
|
14
|
-
type CronConsumerLogger,
|
|
15
|
-
type CronSession,
|
|
16
|
-
} from './consumer'
|
|
17
|
-
export {
|
|
18
|
-
type ComputeNextFireResult,
|
|
19
|
-
computeNextFire,
|
|
20
|
-
createScheduler,
|
|
21
|
-
type JobDiff,
|
|
22
|
-
type Scheduler,
|
|
23
|
-
type SchedulerLogger,
|
|
24
|
-
} from './scheduler'
|
|
9
|
+
export { type CountStore, createCountStore } from './count-state'
|
|
10
|
+
export { createCronReloadable } from './reloadable'
|
|
11
|
+
export { createCronConsumer, type CronConsumer } from './consumer'
|
|
12
|
+
export { computeNextFire, createScheduler, type JobDiff, type Scheduler } from './scheduler'
|
|
25
13
|
export { aggregateCronList, type CronListEntry, type CronListSource } from './list'
|
|
26
14
|
export {
|
|
27
15
|
cronFileSchema,
|
|
@@ -31,7 +19,6 @@ export {
|
|
|
31
19
|
type ExecJob,
|
|
32
20
|
type HandlerJob,
|
|
33
21
|
parseCronJson,
|
|
34
|
-
type ParseCronJsonOptions,
|
|
35
22
|
type ParseCronResult,
|
|
36
23
|
type ParsedCronJob,
|
|
37
24
|
type PromptJob,
|
package/src/cron/list.ts
CHANGED
|
@@ -15,7 +15,10 @@ export type CronListEntry = {
|
|
|
15
15
|
id: string
|
|
16
16
|
source: CronListSource
|
|
17
17
|
kind: 'prompt' | 'exec' | 'handler'
|
|
18
|
-
schedule: string
|
|
18
|
+
schedule: string | undefined
|
|
19
|
+
at: string | undefined
|
|
20
|
+
until: string | undefined
|
|
21
|
+
count: number | undefined
|
|
19
22
|
timezone: string | undefined
|
|
20
23
|
enabled: boolean
|
|
21
24
|
scheduledByRole: string | undefined
|
|
@@ -35,15 +38,27 @@ export type AggregateCronListOptions = {
|
|
|
35
38
|
// to its plugin + localId without re-parsing the global id.
|
|
36
39
|
pluginJobs: readonly RegisteredCronJob[]
|
|
37
40
|
now: number
|
|
41
|
+
// Durable fire progress for count-limited jobs. Without this, an exhausted
|
|
42
|
+
// count job would render with a future next-fire time and lie about being
|
|
43
|
+
// retired, so the listing threads the same firedCount the scheduler uses.
|
|
44
|
+
firedCount?: (job: CronJob) => number
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
export function aggregateCronList(opts: AggregateCronListOptions): CronListEntry[] {
|
|
48
|
+
const firedCount = opts.firedCount ?? (() => 0)
|
|
41
49
|
const entries: CronListEntry[] = []
|
|
42
50
|
for (const job of opts.userJobs) {
|
|
43
|
-
entries.push(toEntry(job, { kind: 'user' }, opts.now))
|
|
51
|
+
entries.push(toEntry(job, { kind: 'user' }, opts.now, firedCount(job)))
|
|
44
52
|
}
|
|
45
53
|
for (const reg of opts.pluginJobs) {
|
|
46
|
-
entries.push(
|
|
54
|
+
entries.push(
|
|
55
|
+
toEntry(
|
|
56
|
+
reg.job,
|
|
57
|
+
{ kind: 'plugin', pluginName: reg.pluginName, localId: reg.localId },
|
|
58
|
+
opts.now,
|
|
59
|
+
firedCount(reg.job),
|
|
60
|
+
),
|
|
61
|
+
)
|
|
47
62
|
}
|
|
48
63
|
// Sort by next-fire time ascending so the soonest-firing job is at the
|
|
49
64
|
// top. Jobs with a null nextFireMs (parse errors) sort to the bottom
|
|
@@ -55,17 +70,20 @@ export function aggregateCronList(opts: AggregateCronListOptions): CronListEntry
|
|
|
55
70
|
return entries
|
|
56
71
|
}
|
|
57
72
|
|
|
58
|
-
function toEntry(job: CronJob, source: CronListSource, now: number): CronListEntry {
|
|
59
|
-
const fire = computeNextFire(job, now)
|
|
73
|
+
function toEntry(job: CronJob, source: CronListSource, now: number, firedCount: number): CronListEntry {
|
|
74
|
+
const fire = computeNextFire(job, now, { firedCount })
|
|
60
75
|
const base = {
|
|
61
76
|
id: job.id,
|
|
62
77
|
source,
|
|
63
78
|
schedule: job.schedule,
|
|
79
|
+
at: job.at,
|
|
80
|
+
until: job.until,
|
|
81
|
+
count: job.count,
|
|
64
82
|
timezone: job.timezone,
|
|
65
83
|
enabled: job.enabled,
|
|
66
84
|
scheduledByRole: job.scheduledByRole,
|
|
67
85
|
nextFireMs: fire.ok ? fire.nextFire : null,
|
|
68
|
-
scheduleError: fire.ok ? undefined : fire.reason,
|
|
86
|
+
scheduleError: fire.ok || fire.expired ? undefined : fire.reason,
|
|
69
87
|
} as const
|
|
70
88
|
if (job.kind === 'prompt') {
|
|
71
89
|
return {
|
package/src/cron/scheduler.ts
CHANGED
|
@@ -14,11 +14,22 @@ export type SchedulerLogger = {
|
|
|
14
14
|
error: (msg: string) => void
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// The scheduler uses the count store only to stop arming once a job's durable
|
|
18
|
+
// count is exhausted (an optimization) and to reconcile progress on reload.
|
|
19
|
+
// The authoritative count gate lives in the consumer, which owns accepted-fire
|
|
20
|
+
// accounting — the scheduler must not be the correctness boundary because it
|
|
21
|
+
// can't know whether the downstream coalescer will accept or skip a fire.
|
|
22
|
+
export type SchedulerCountStore = {
|
|
23
|
+
get: (id: string, job: CronJob) => number
|
|
24
|
+
reconcile: (jobs: CronJob[]) => Promise<void>
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
export type CreateSchedulerOptions = {
|
|
18
28
|
jobs: CronJob[]
|
|
19
29
|
onFire: (job: CronJob) => void
|
|
20
30
|
clock?: SchedulerClock
|
|
21
31
|
logger?: SchedulerLogger
|
|
32
|
+
countStore?: SchedulerCountStore
|
|
22
33
|
}
|
|
23
34
|
|
|
24
35
|
export type JobDiff = {
|
|
@@ -51,6 +62,7 @@ export function createScheduler({
|
|
|
51
62
|
onFire,
|
|
52
63
|
clock = realClock,
|
|
53
64
|
logger = consoleLogger,
|
|
65
|
+
countStore,
|
|
54
66
|
}: CreateSchedulerOptions): Scheduler {
|
|
55
67
|
const registry = new Map<string, CronJob>()
|
|
56
68
|
for (const job of jobs) registry.set(job.id, job)
|
|
@@ -69,9 +81,16 @@ export function createScheduler({
|
|
|
69
81
|
const job = currentEnabled(id)
|
|
70
82
|
if (!job) return
|
|
71
83
|
|
|
72
|
-
const
|
|
84
|
+
const firedCount = job.count !== undefined ? (countStore?.get(id, job) ?? 0) : 0
|
|
85
|
+
const result = computeNextFire(job, clock.now(), { firedCount })
|
|
73
86
|
if (!result.ok) {
|
|
74
|
-
|
|
87
|
+
if (result.expired) {
|
|
88
|
+
logger.info(`[cron] ${id} retired: ${result.reason}`)
|
|
89
|
+
} else {
|
|
90
|
+
logger.warn(
|
|
91
|
+
`[cron] ${id} not scheduled: invalid schedule "${job.schedule ?? job.at}"${tzSuffix(job)}: ${result.reason}`,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
75
94
|
return
|
|
76
95
|
}
|
|
77
96
|
|
|
@@ -94,8 +113,7 @@ export function createScheduler({
|
|
|
94
113
|
try {
|
|
95
114
|
onFire(job)
|
|
96
115
|
} catch (err) {
|
|
97
|
-
|
|
98
|
-
logger.error(`[cron] ${job.id} onFire threw synchronously: ${message}`)
|
|
116
|
+
logger.error(`[cron] ${job.id} onFire threw synchronously: ${describe(err)}`)
|
|
99
117
|
}
|
|
100
118
|
}
|
|
101
119
|
|
|
@@ -153,6 +171,14 @@ export function createScheduler({
|
|
|
153
171
|
registry.clear()
|
|
154
172
|
for (const [id, job] of newRegistry) registry.set(id, job)
|
|
155
173
|
|
|
174
|
+
// Reconcile counts before arming so re-added/changed jobs don't inherit
|
|
175
|
+
// stale progress. `reconcile` settles the authoritative in-memory map
|
|
176
|
+
// synchronously; only the persist is async, so a failure there just means
|
|
177
|
+
// disk lags the (correct) in-memory state — log it, don't fail the reload.
|
|
178
|
+
countStore?.reconcile(next).catch((err) => {
|
|
179
|
+
logger.error(`[cron] failed to persist count reconciliation: ${describe(err)}`)
|
|
180
|
+
})
|
|
181
|
+
|
|
156
182
|
for (const job of result.removed) cancel(job.id)
|
|
157
183
|
for (const job of result.updated) {
|
|
158
184
|
cancel(job.id)
|
|
@@ -167,7 +193,10 @@ export function createScheduler({
|
|
|
167
193
|
|
|
168
194
|
function jobFingerprint(job: CronJob): string {
|
|
169
195
|
return JSON.stringify({
|
|
170
|
-
schedule: job.schedule,
|
|
196
|
+
schedule: job.schedule ?? null,
|
|
197
|
+
at: job.at ?? null,
|
|
198
|
+
until: job.until ?? null,
|
|
199
|
+
count: job.count ?? null,
|
|
171
200
|
enabled: job.enabled,
|
|
172
201
|
timezone: job.timezone ?? null,
|
|
173
202
|
kind: job.kind,
|
|
@@ -189,21 +218,67 @@ function jobPayload(job: CronJob): unknown {
|
|
|
189
218
|
return { handler: String(job.handler) }
|
|
190
219
|
}
|
|
191
220
|
|
|
192
|
-
export type ComputeNextFireResult =
|
|
221
|
+
export type ComputeNextFireResult =
|
|
222
|
+
| { ok: true; nextFire: number }
|
|
223
|
+
// `expired` distinguishes a reached end-boundary (count/until/past `at`) from
|
|
224
|
+
// a malformed schedule: the former is silent and final, the latter warns.
|
|
225
|
+
| { ok: false; expired: true; reason: string }
|
|
226
|
+
| { ok: false; expired: false; reason: string }
|
|
193
227
|
|
|
194
|
-
export
|
|
228
|
+
export type ComputeNextFireOptions = {
|
|
229
|
+
// Accepted fires so far, sourced from the count store. Defaults to 0 so
|
|
230
|
+
// callers that don't track counts (cron list rendering, pure schedule
|
|
231
|
+
// preview) compute the raw next occurrence.
|
|
232
|
+
firedCount?: number
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function computeNextFire(
|
|
236
|
+
job: CronJob,
|
|
237
|
+
now: number,
|
|
238
|
+
options: ComputeNextFireOptions = {},
|
|
239
|
+
): ComputeNextFireResult {
|
|
240
|
+
const firedCount = options.firedCount ?? 0
|
|
241
|
+
if (job.count !== undefined && firedCount >= job.count) {
|
|
242
|
+
return { ok: false, expired: true, reason: `count limit reached (${firedCount}/${job.count})` }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (job.at !== undefined) {
|
|
246
|
+
const at = Date.parse(job.at)
|
|
247
|
+
if (Number.isNaN(at)) return { ok: false, expired: false, reason: `invalid "at": ${job.at}` }
|
|
248
|
+
if (at <= now) return { ok: false, expired: true, reason: `one-shot "at" already elapsed` }
|
|
249
|
+
return { ok: true, nextFire: at }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (job.schedule === undefined) {
|
|
253
|
+
return { ok: false, expired: false, reason: `job has neither "schedule" nor "at"` }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let nextFire: number
|
|
195
257
|
try {
|
|
196
258
|
const expr = CronExpressionParser.parse(job.schedule, {
|
|
197
259
|
currentDate: new Date(now),
|
|
198
260
|
...(job.timezone ? { tz: job.timezone } : {}),
|
|
199
261
|
})
|
|
200
|
-
|
|
262
|
+
nextFire = expr.next().getTime()
|
|
201
263
|
} catch (err) {
|
|
202
264
|
const reason = err instanceof Error ? err.message : String(err)
|
|
203
|
-
return { ok: false, reason }
|
|
265
|
+
return { ok: false, expired: false, reason }
|
|
204
266
|
}
|
|
267
|
+
|
|
268
|
+
if (job.until !== undefined) {
|
|
269
|
+
const until = Date.parse(job.until)
|
|
270
|
+
if (!Number.isNaN(until) && nextFire > until) {
|
|
271
|
+
return { ok: false, expired: true, reason: `next occurrence is after "until" (${job.until})` }
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { ok: true, nextFire }
|
|
205
276
|
}
|
|
206
277
|
|
|
207
278
|
function tzSuffix(job: CronJob): string {
|
|
208
279
|
return job.timezone ? ` (timezone "${job.timezone}")` : ''
|
|
209
280
|
}
|
|
281
|
+
|
|
282
|
+
function describe(err: unknown): string {
|
|
283
|
+
return err instanceof Error ? err.message : String(err)
|
|
284
|
+
}
|
package/src/cron/schema.ts
CHANGED
|
@@ -9,7 +9,18 @@ const idPattern = /^[a-zA-Z0-9_-]+$/
|
|
|
9
9
|
|
|
10
10
|
const baseJob = z.object({
|
|
11
11
|
id: z.string().min(1).regex(idPattern, 'id must contain only letters, digits, hyphens, or underscores'),
|
|
12
|
-
schedule
|
|
12
|
+
// `schedule` (recurring cron expression) and `at` (one-shot ISO instant) are
|
|
13
|
+
// mutually exclusive: exactly one must be present. Zod marks both optional so
|
|
14
|
+
// the discriminated union still parses; the XOR is enforced in parseCronFile
|
|
15
|
+
// where we can emit a job-id-scoped error message.
|
|
16
|
+
schedule: z.string().min(1).optional(),
|
|
17
|
+
at: z.string().min(1).optional(),
|
|
18
|
+
// End boundaries. `until` is an absolute ISO instant (last allowed fire,
|
|
19
|
+
// inclusive); `count` stops after N accepted fires. Both may coexist on one
|
|
20
|
+
// job — the scheduler stops at whichever limit is reached first. `count`
|
|
21
|
+
// progress is tracked out-of-band in cron-state.json, never written back here.
|
|
22
|
+
until: z.string().min(1).optional(),
|
|
23
|
+
count: z.number().int().positive().optional(),
|
|
13
24
|
enabled: z.boolean().default(true),
|
|
14
25
|
timezone: z.string().optional(),
|
|
15
26
|
scheduledByRole: z.string().optional(),
|
|
@@ -57,11 +68,25 @@ export type CronFile = z.infer<typeof cronFileSchema>
|
|
|
57
68
|
|
|
58
69
|
export type ParseCronResult = { ok: true; file: CronFile } | { ok: false; reason: string }
|
|
59
70
|
|
|
71
|
+
// `edit` is the strict path for an agent writing/editing cron.json: a past
|
|
72
|
+
// enabled `at` is rejected so a reminder scheduled in the past surfaces as an
|
|
73
|
+
// error instead of silently becoming a never-firing no-op. `load` is the
|
|
74
|
+
// tolerant path the scheduler uses on boot/reload: a fired or missed one-shot
|
|
75
|
+
// lingers on disk with a now-past `at`, and rejecting it would brick the whole
|
|
76
|
+
// file — so `load` accepts it and lets the scheduler retire it passively.
|
|
77
|
+
export type ParseCronMode = 'edit' | 'load'
|
|
78
|
+
|
|
60
79
|
export type ParseCronOptions = {
|
|
61
80
|
// When provided, prompt jobs with a `subagent` field are validated against
|
|
62
81
|
// the registry: the name must exist, and the optional `payload` must match
|
|
63
82
|
// the registered subagent's payloadSchema (or be absent if no schema).
|
|
64
83
|
subagents?: SubagentRegistry
|
|
84
|
+
// Injected by tests so past/future boundary checks on `at`/`until` are
|
|
85
|
+
// deterministic. Production omits it and validation reads the wall clock.
|
|
86
|
+
now?: number
|
|
87
|
+
// Defaults to `load` (reload-safe). Callers validating an agent edit pass
|
|
88
|
+
// `edit` to reject newly-scheduled past `at` reminders.
|
|
89
|
+
mode?: ParseCronMode
|
|
65
90
|
}
|
|
66
91
|
|
|
67
92
|
export type ParseCronJsonOptions = ParseCronOptions
|
|
@@ -74,7 +99,11 @@ export function parseCronJson(raw: string, options: ParseCronJsonOptions = {}):
|
|
|
74
99
|
return { ok: false, reason: `cron.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }
|
|
75
100
|
}
|
|
76
101
|
|
|
77
|
-
return parseCronFile(json,
|
|
102
|
+
return parseCronFile(json, {
|
|
103
|
+
...(options.subagents !== undefined ? { subagents: options.subagents } : {}),
|
|
104
|
+
...(options.now !== undefined ? { now: options.now } : {}),
|
|
105
|
+
...(options.mode !== undefined ? { mode: options.mode } : {}),
|
|
106
|
+
})
|
|
78
107
|
}
|
|
79
108
|
|
|
80
109
|
export function parseCronFile(raw: unknown, options: ParseCronOptions = {}): ParseCronResult {
|
|
@@ -91,17 +120,9 @@ export function parseCronFile(raw: unknown, options: ParseCronOptions = {}): Par
|
|
|
91
120
|
}
|
|
92
121
|
seen.add(job.id)
|
|
93
122
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
// parse time, so we must force evaluation here to catch bogus zones.
|
|
98
|
-
expr.next()
|
|
99
|
-
} catch (err) {
|
|
100
|
-
const message = err instanceof Error ? err.message : String(err)
|
|
101
|
-
if (job.timezone && /invalid|unhandled timestamp|unrecognized/i.test(message)) {
|
|
102
|
-
return { ok: false, reason: `job ${job.id}: invalid timezone "${job.timezone}": ${message}` }
|
|
103
|
-
}
|
|
104
|
-
return { ok: false, reason: `job ${job.id}: invalid schedule "${job.schedule}": ${message}` }
|
|
123
|
+
const timingError = validateTiming(job, options.now ?? Date.now(), options.mode ?? 'load')
|
|
124
|
+
if (timingError !== null) {
|
|
125
|
+
return { ok: false, reason: `job ${job.id}: ${timingError}` }
|
|
105
126
|
}
|
|
106
127
|
|
|
107
128
|
if (job.scheduledByRole === undefined) {
|
|
@@ -128,6 +149,72 @@ export function parseCronFile(raw: unknown, options: ParseCronOptions = {}): Par
|
|
|
128
149
|
return { ok: true, file }
|
|
129
150
|
}
|
|
130
151
|
|
|
152
|
+
type TimingJob = {
|
|
153
|
+
schedule?: string | undefined
|
|
154
|
+
at?: string | undefined
|
|
155
|
+
until?: string | undefined
|
|
156
|
+
count?: number | undefined
|
|
157
|
+
timezone?: string | undefined
|
|
158
|
+
enabled: boolean
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function validateTiming(job: TimingJob, now: number = Date.now(), mode: ParseCronMode = 'load'): string | null {
|
|
162
|
+
const hasSchedule = job.schedule !== undefined
|
|
163
|
+
const hasAt = job.at !== undefined
|
|
164
|
+
if (hasSchedule === hasAt) {
|
|
165
|
+
return `must set exactly one of "schedule" (recurring) or "at" (one-shot)`
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (hasAt) {
|
|
169
|
+
if (job.timezone !== undefined) return `"timezone" is only valid with "schedule", not "at"`
|
|
170
|
+
if (job.until !== undefined) return `"until" is only valid with "schedule", not "at"`
|
|
171
|
+
if (job.count !== undefined && job.count !== 1) return `one-shot "at" jobs may only set "count": 1`
|
|
172
|
+
const at = parseInstant(job.at!)
|
|
173
|
+
if (at === null) return `invalid "at": "${job.at}" is not an ISO datetime with an explicit zone/offset`
|
|
174
|
+
// Reject a past reminder only on `edit`; `load` tolerates fired tombstones.
|
|
175
|
+
// See ParseCronMode for the full rationale.
|
|
176
|
+
if (mode === 'edit' && job.enabled && at <= now) return `"at" is in the past: "${job.at}"`
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let until: number | null = null
|
|
181
|
+
if (job.until !== undefined) {
|
|
182
|
+
until = parseInstant(job.until)
|
|
183
|
+
if (until === null) return `invalid "until": "${job.until}" is not an ISO datetime with an explicit zone/offset`
|
|
184
|
+
if (job.enabled && until <= now) return `"until" is in the past: "${job.until}"`
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let firstFire: number
|
|
188
|
+
try {
|
|
189
|
+
const expr = CronExpressionParser.parse(job.schedule!, {
|
|
190
|
+
currentDate: new Date(now),
|
|
191
|
+
...(job.timezone ? { tz: job.timezone } : {}),
|
|
192
|
+
})
|
|
193
|
+
firstFire = expr.next().getTime()
|
|
194
|
+
} catch (err) {
|
|
195
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
196
|
+
if (job.timezone && /invalid|unhandled timestamp|unrecognized/i.test(message)) {
|
|
197
|
+
return `invalid timezone "${job.timezone}": ${message}`
|
|
198
|
+
}
|
|
199
|
+
return `invalid schedule "${job.schedule}": ${message}`
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (job.enabled && until !== null && firstFire > until) {
|
|
203
|
+
return `schedule has no occurrence at or before "until" ("${job.until}")`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Accepts only absolute instants: an explicit `Z` or numeric offset is
|
|
210
|
+
// required so "remind me at 9am" can never silently resolve to the host's
|
|
211
|
+
// local zone. A bare `2026-06-09T09:00:00` (no zone) is rejected.
|
|
212
|
+
function parseInstant(value: string): number | null {
|
|
213
|
+
if (!/[zZ]$|[+-]\d{2}:?\d{2}$/.test(value)) return null
|
|
214
|
+
const ms = Date.parse(value)
|
|
215
|
+
return Number.isNaN(ms) ? null : ms
|
|
216
|
+
}
|
|
217
|
+
|
|
131
218
|
function formatIssue(issue: { path: PropertyKey[]; message: string }): string {
|
|
132
219
|
const path = issue.path.length > 0 ? issue.path.map(String).join('.') : '<root>'
|
|
133
220
|
return `${path}: ${issue.message}`
|
|
@@ -28,6 +28,7 @@ export function buildChannelChecks(): DoctorCheck[] {
|
|
|
28
28
|
slackBotCredentials(),
|
|
29
29
|
discordBotCredentials(),
|
|
30
30
|
telegramBotCredentials(),
|
|
31
|
+
lineCredentials(),
|
|
31
32
|
kakaotalkCredentials(),
|
|
32
33
|
githubCredentials(),
|
|
33
34
|
githubWebhookDelivery(),
|
|
@@ -64,6 +65,33 @@ function telegramBotCredentials(): DoctorCheck {
|
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
function lineCredentials(): DoctorCheck {
|
|
69
|
+
return {
|
|
70
|
+
name: 'channel.line.credentials',
|
|
71
|
+
category: 'channels',
|
|
72
|
+
description: 'line adapter has at least one account in secrets.json',
|
|
73
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
74
|
+
async run(ctx) {
|
|
75
|
+
const channels = readDeclaredChannels(ctx)
|
|
76
|
+
if (channels === null) return configInvalidResult()
|
|
77
|
+
if (!isAdapterActive(channels, 'line')) {
|
|
78
|
+
return { status: 'skipped', message: 'line not configured' }
|
|
79
|
+
}
|
|
80
|
+
const block = readChannelsSecrets(ctx)?.line
|
|
81
|
+
const accountCount = block?.accounts ? Object.keys(block.accounts).length : 0
|
|
82
|
+
if (accountCount === 0) {
|
|
83
|
+
return {
|
|
84
|
+
status: 'warning',
|
|
85
|
+
message: 'line has no accounts in secrets.json',
|
|
86
|
+
details: ['Adapter will start but fail authentication and stay disconnected.'],
|
|
87
|
+
fix: { description: 'Run `typeclaw channel add line` to log in an account.' },
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { status: 'ok', message: `line has ${accountCount} account(s)` }
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
67
95
|
function kakaotalkCredentials(): DoctorCheck {
|
|
68
96
|
return {
|
|
69
97
|
name: 'channel.kakaotalk.credentials',
|