@app-vitals/agent-runtime 0.3.0 → 0.4.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/boot.ts +56 -0
- package/cron-handler.ts +10 -1
- package/cron-handler.unit.test.ts +28 -0
- package/cron-run-now.ts +116 -0
- package/cron-run-now.unit.test.ts +156 -0
- package/cutover-validate.integration.test.ts +2 -0
- package/package.json +3 -3
- package/shipwright-runtime-client.integration.test.ts +2 -0
package/boot.ts
CHANGED
|
@@ -58,6 +58,7 @@ import { createContextStampReader } from "./context-stamp.ts";
|
|
|
58
58
|
import { reportCronFailure } from "./cron-failure-reporter.ts";
|
|
59
59
|
import type { CronHandlerDeps } from "./cron-handler.ts";
|
|
60
60
|
import { handleCronRequest } from "./cron-handler.ts";
|
|
61
|
+
import { createRunNowHonourer } from "./cron-run-now.ts";
|
|
61
62
|
import {
|
|
62
63
|
HttpCronRunReporter,
|
|
63
64
|
NoopCronRunReporter,
|
|
@@ -684,6 +685,54 @@ export async function startAgent(
|
|
|
684
685
|
const cronTasks = new Map<string, ReturnType<typeof nodeCron.schedule>>();
|
|
685
686
|
|
|
686
687
|
if (runtimeClient && agentId) {
|
|
688
|
+
// Run-now requests (COS-42.1): honoured at most once each, in memory.
|
|
689
|
+
// Dispatch kind per job from the latest sync, so run-now takes the same
|
|
690
|
+
// path a scheduled tick would (loop-classified crons go through dispatchLoop).
|
|
691
|
+
let dispatchKinds = new Map<string, "loop" | "generic">();
|
|
692
|
+
const honourRunNow = createRunNowHonourer({
|
|
693
|
+
clock: SystemClock(),
|
|
694
|
+
dispatch: async (req) => {
|
|
695
|
+
if (dispatchKinds.get(req.jobId) === "loop") {
|
|
696
|
+
if (!extension.dispatchLoop) {
|
|
697
|
+
throw new Error(
|
|
698
|
+
`cron job ${req.jobId} classified as loop but the agent extension has no dispatchLoop`,
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
// dispatchLoop creates no run-now run of its own, so record one here:
|
|
702
|
+
// creating it clears the stamp and flips the audit row to honoured.
|
|
703
|
+
const reporter = cronRunReporter ?? new NoopCronRunReporter();
|
|
704
|
+
const startedAt = SystemClock().now();
|
|
705
|
+
const runId = await reporter.createRun(
|
|
706
|
+
req.jobId,
|
|
707
|
+
startedAt,
|
|
708
|
+
undefined,
|
|
709
|
+
undefined,
|
|
710
|
+
undefined,
|
|
711
|
+
req.commandVariant,
|
|
712
|
+
);
|
|
713
|
+
let outcome: "completed" | "failed" = "completed";
|
|
714
|
+
try {
|
|
715
|
+
await extension.dispatchLoop(req.jobId);
|
|
716
|
+
} catch (err) {
|
|
717
|
+
outcome = "failed";
|
|
718
|
+
throw err;
|
|
719
|
+
} finally {
|
|
720
|
+
await reporter
|
|
721
|
+
.completeRun(req.jobId, runId, SystemClock().now(), outcome)
|
|
722
|
+
.catch(() => {});
|
|
723
|
+
}
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
await handleCronRequest(req, cronDeps);
|
|
727
|
+
},
|
|
728
|
+
onError: (jobId, err) =>
|
|
729
|
+
reportCronFailure(jobId, err, {
|
|
730
|
+
cronRunReporter: cronRunReporter ?? new NoopCronRunReporter(),
|
|
731
|
+
sentryClient: process.env.SENTRY_DSN ? Sentry : undefined,
|
|
732
|
+
clock: SystemClock(),
|
|
733
|
+
}),
|
|
734
|
+
});
|
|
735
|
+
|
|
687
736
|
async function syncCrons() {
|
|
688
737
|
if (!runtimeClient || !agentId) return;
|
|
689
738
|
let jobs: Awaited<ReturnType<typeof runtimeClient.listAgentCronJobs>>;
|
|
@@ -705,6 +754,13 @@ export async function startAgent(
|
|
|
705
754
|
// the coding extension's loop-cron-classifier.ts for the full decision.
|
|
706
755
|
const scheduled = planCronSchedule(extension, jobs);
|
|
707
756
|
const desired = new Map(scheduled.map((entry) => [entry.job.id, entry]));
|
|
757
|
+
dispatchKinds = new Map(
|
|
758
|
+
scheduled.map((entry) => [entry.job.id, entry.dispatch]),
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
// Fire-and-forget: a run can take minutes and must not hold up the sync.
|
|
762
|
+
// Only scheduled (enabled) jobs: a manually disabled cron is not run.
|
|
763
|
+
void honourRunNow(jobs.filter((job) => desired.has(job.id)));
|
|
708
764
|
|
|
709
765
|
// Cancel removed/disabled jobs
|
|
710
766
|
for (const [id, task] of cronTasks) {
|
package/cron-handler.ts
CHANGED
|
@@ -162,6 +162,8 @@ interface CronRequest {
|
|
|
162
162
|
user?: string;
|
|
163
163
|
silent?: boolean;
|
|
164
164
|
preCheck?: string;
|
|
165
|
+
/** Run-attribution tag, e.g. "run-now" (COS-42.1); passed to createRun. */
|
|
166
|
+
commandVariant?: string;
|
|
165
167
|
}
|
|
166
168
|
|
|
167
169
|
export interface CronHandlerDeps {
|
|
@@ -266,7 +268,14 @@ export async function handleCronRequest(
|
|
|
266
268
|
|
|
267
269
|
// CREATE run at start (before preCheck)
|
|
268
270
|
const runId = cronRunReporter
|
|
269
|
-
? await cronRunReporter.createRun(
|
|
271
|
+
? await cronRunReporter.createRun(
|
|
272
|
+
jobId,
|
|
273
|
+
startedAt,
|
|
274
|
+
undefined,
|
|
275
|
+
undefined,
|
|
276
|
+
undefined,
|
|
277
|
+
req.commandVariant,
|
|
278
|
+
)
|
|
270
279
|
: null;
|
|
271
280
|
|
|
272
281
|
// Best-effort redacted transcript upload (COS-43.1). Runs only after the
|
|
@@ -1176,6 +1176,34 @@ describe("handleCronRequest — telemetry forwarding", () => {
|
|
|
1176
1176
|
});
|
|
1177
1177
|
});
|
|
1178
1178
|
|
|
1179
|
+
describe("handleCronRequest — commandVariant (COS-42.1)", () => {
|
|
1180
|
+
test("a run-now request creates its run with commandVariant run-now", async () => {
|
|
1181
|
+
const created: unknown[][] = [];
|
|
1182
|
+
const reporter: CronRunReporter = {
|
|
1183
|
+
async createRun(...args: unknown[]) {
|
|
1184
|
+
created.push(args);
|
|
1185
|
+
return "run-now-1";
|
|
1186
|
+
},
|
|
1187
|
+
async completeRun() {},
|
|
1188
|
+
async skipRun() {},
|
|
1189
|
+
async recordProgress() {},
|
|
1190
|
+
async recordSessionId() {},
|
|
1191
|
+
};
|
|
1192
|
+
await handleCronRequest(
|
|
1193
|
+
{
|
|
1194
|
+
jobId: "j-now",
|
|
1195
|
+
prompt: "hello",
|
|
1196
|
+
silent: true,
|
|
1197
|
+
commandVariant: "run-now",
|
|
1198
|
+
},
|
|
1199
|
+
{ ...deps, cronRunReporter: reporter },
|
|
1200
|
+
);
|
|
1201
|
+
expect(created).toHaveLength(1);
|
|
1202
|
+
expect(created[0]?.[0]).toBe("j-now");
|
|
1203
|
+
expect(created[0]?.[5]).toBe("run-now");
|
|
1204
|
+
});
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1179
1207
|
// ─── transcript upload hook (COS-43.1) ───────────────────────────────────────
|
|
1180
1208
|
|
|
1181
1209
|
describe("handleCronRequest — transcript upload (COS-43.1)", () => {
|
package/cron-run-now.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime/cron-run-now.ts
|
|
3
|
+
*
|
|
4
|
+
* Honours cron run-now requests (COS-42.1). The admin stamps
|
|
5
|
+
* `runRequestedAt` on a stored cron job; on each cron sync tick the runtime
|
|
6
|
+
* runs the job once when the stamp is strictly newer than the last one it
|
|
7
|
+
* honoured (held in memory) and at most 15 minutes old. An older stamp is
|
|
8
|
+
* logged as expired and never run. The request is dispatched with the job's
|
|
9
|
+
* own stored fields, never a prompt from anywhere else, and tagged
|
|
10
|
+
* commandVariant "run-now" so run creation clears the stamp admin-side.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Clock } from "./clock.ts";
|
|
14
|
+
|
|
15
|
+
/** A request older than this is expired and never run. */
|
|
16
|
+
export const RUN_NOW_FRESH_MS = 15 * 60_000;
|
|
17
|
+
|
|
18
|
+
/** The stored cron job fields the honourer reads. */
|
|
19
|
+
export interface RunNowJob {
|
|
20
|
+
id: string;
|
|
21
|
+
prompt: string;
|
|
22
|
+
channel?: string | null;
|
|
23
|
+
user?: string | null;
|
|
24
|
+
silent?: boolean;
|
|
25
|
+
preCheck?: string | null;
|
|
26
|
+
/** A manually disabled job is never run, even with a fresh stamp. */
|
|
27
|
+
enabled?: boolean;
|
|
28
|
+
/** ISO string on the wire (typed Date by the generated client). */
|
|
29
|
+
runRequestedAt?: string | Date | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RunNowRequest {
|
|
33
|
+
jobId: string;
|
|
34
|
+
prompt: string;
|
|
35
|
+
channel?: string;
|
|
36
|
+
user?: string;
|
|
37
|
+
silent?: boolean;
|
|
38
|
+
preCheck?: string;
|
|
39
|
+
commandVariant: "run-now";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface RunNowHonourerDeps {
|
|
43
|
+
clock: Clock;
|
|
44
|
+
/** Runs the job (handleCronRequest in production). */
|
|
45
|
+
dispatch: (req: RunNowRequest) => Promise<void>;
|
|
46
|
+
/** Called when a dispatch throws; the request is not retried. */
|
|
47
|
+
onError?: (jobId: string, err: unknown) => void | Promise<void>;
|
|
48
|
+
log?: (line: string) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Returns a function to call with the fresh job list on every sync tick. It
|
|
53
|
+
* resolves once every dispatch it started has settled; callers that must not
|
|
54
|
+
* wait on long runs simply do not await it.
|
|
55
|
+
*/
|
|
56
|
+
export function createRunNowHonourer(
|
|
57
|
+
deps: RunNowHonourerDeps,
|
|
58
|
+
): (jobs: readonly RunNowJob[]) => Promise<void> {
|
|
59
|
+
const { clock, dispatch, onError, log = console.log } = deps;
|
|
60
|
+
// jobId -> last honoured / last expired-logged runRequestedAt (epoch ms).
|
|
61
|
+
const lastHonoured = new Map<string, number>();
|
|
62
|
+
const expiredLogged = new Map<string, number>();
|
|
63
|
+
|
|
64
|
+
return async (jobs) => {
|
|
65
|
+
const running: Promise<void>[] = [];
|
|
66
|
+
for (const job of jobs) {
|
|
67
|
+
if (!job.runRequestedAt || job.enabled === false) continue;
|
|
68
|
+
const requestedMs = new Date(job.runRequestedAt).getTime();
|
|
69
|
+
if (Number.isNaN(requestedMs)) continue;
|
|
70
|
+
const requested = new Date(requestedMs).toISOString();
|
|
71
|
+
|
|
72
|
+
const seen = lastHonoured.get(job.id);
|
|
73
|
+
if (seen !== undefined && requestedMs <= seen) continue;
|
|
74
|
+
|
|
75
|
+
if (clock.now().getTime() - requestedMs > RUN_NOW_FRESH_MS) {
|
|
76
|
+
if (expiredLogged.get(job.id) !== requestedMs) {
|
|
77
|
+
expiredLogged.set(job.id, requestedMs);
|
|
78
|
+
log(
|
|
79
|
+
JSON.stringify({
|
|
80
|
+
event: "cron-run-now",
|
|
81
|
+
status: "expired",
|
|
82
|
+
jobId: job.id,
|
|
83
|
+
requestedAt: requested,
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Recorded before dispatch so a slow or failing run is never repeated.
|
|
91
|
+
lastHonoured.set(job.id, requestedMs);
|
|
92
|
+
log(
|
|
93
|
+
JSON.stringify({
|
|
94
|
+
event: "cron-run-now",
|
|
95
|
+
status: "honouring",
|
|
96
|
+
jobId: job.id,
|
|
97
|
+
requestedAt: requested,
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
running.push(
|
|
101
|
+
dispatch({
|
|
102
|
+
jobId: job.id,
|
|
103
|
+
prompt: job.prompt,
|
|
104
|
+
channel: job.channel ?? undefined,
|
|
105
|
+
user: job.user ?? undefined,
|
|
106
|
+
silent: job.silent,
|
|
107
|
+
preCheck: job.preCheck ?? undefined,
|
|
108
|
+
commandVariant: "run-now",
|
|
109
|
+
}).catch(async (err) => {
|
|
110
|
+
await onError?.(job.id, err);
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
await Promise.all(running);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime/cron-run-now.unit.test.ts
|
|
3
|
+
* Honour / expire / once logic for cron run-now requests (COS-42.1), with an
|
|
4
|
+
* injected clock and dispatch.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
createRunNowHonourer,
|
|
10
|
+
RUN_NOW_FRESH_MS,
|
|
11
|
+
type RunNowJob,
|
|
12
|
+
} from "./cron-run-now.ts";
|
|
13
|
+
|
|
14
|
+
const NOW = new Date("2026-10-26T12:00:00.000Z");
|
|
15
|
+
|
|
16
|
+
function job(over: Partial<RunNowJob> = {}): RunNowJob {
|
|
17
|
+
return {
|
|
18
|
+
id: "job-1",
|
|
19
|
+
prompt: "stored prompt",
|
|
20
|
+
channel: "C1",
|
|
21
|
+
user: null,
|
|
22
|
+
silent: false,
|
|
23
|
+
preCheck: null,
|
|
24
|
+
runRequestedAt: null,
|
|
25
|
+
...over,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function setup() {
|
|
30
|
+
let nowMs = NOW.getTime();
|
|
31
|
+
const dispatched: Array<Record<string, unknown>> = [];
|
|
32
|
+
const logs: string[] = [];
|
|
33
|
+
const honour = createRunNowHonourer({
|
|
34
|
+
clock: { now: () => new Date(nowMs) },
|
|
35
|
+
dispatch: async (req) => {
|
|
36
|
+
dispatched.push(req as unknown as Record<string, unknown>);
|
|
37
|
+
},
|
|
38
|
+
log: (line) => logs.push(line),
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
honour,
|
|
42
|
+
dispatched,
|
|
43
|
+
logs,
|
|
44
|
+
advance: (ms: number) => {
|
|
45
|
+
nowMs += ms;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString();
|
|
51
|
+
|
|
52
|
+
describe("createRunNowHonourer", () => {
|
|
53
|
+
it("never runs a manually disabled job", async () => {
|
|
54
|
+
const t = setup();
|
|
55
|
+
await t.honour([job({ enabled: false, runRequestedAt: ago(1000) })]);
|
|
56
|
+
expect(t.dispatched).toHaveLength(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("runs a fresh request once with the stored job fields and run-now variant", async () => {
|
|
60
|
+
const { honour, dispatched } = setup();
|
|
61
|
+
await honour([job({ runRequestedAt: ago(60_000) })]);
|
|
62
|
+
expect(dispatched).toEqual([
|
|
63
|
+
{
|
|
64
|
+
jobId: "job-1",
|
|
65
|
+
prompt: "stored prompt",
|
|
66
|
+
channel: "C1",
|
|
67
|
+
user: undefined,
|
|
68
|
+
silent: false,
|
|
69
|
+
preCheck: undefined,
|
|
70
|
+
commandVariant: "run-now",
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("does not run the same request twice across ticks", async () => {
|
|
76
|
+
const { honour, dispatched, advance } = setup();
|
|
77
|
+
const j = job({ runRequestedAt: ago(60_000) });
|
|
78
|
+
await honour([j]);
|
|
79
|
+
advance(60_000);
|
|
80
|
+
await honour([j]);
|
|
81
|
+
await honour([j]);
|
|
82
|
+
expect(dispatched).toHaveLength(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("runs again for a strictly newer request", async () => {
|
|
86
|
+
const { honour, dispatched, advance } = setup();
|
|
87
|
+
await honour([job({ runRequestedAt: ago(60_000) })]);
|
|
88
|
+
advance(120_000);
|
|
89
|
+
await honour([job({ runRequestedAt: ago(10_000) })]);
|
|
90
|
+
expect(dispatched).toHaveLength(2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("ignores an older-or-equal stamp than the last honoured", async () => {
|
|
94
|
+
const { honour, dispatched } = setup();
|
|
95
|
+
await honour([job({ runRequestedAt: ago(10_000) })]);
|
|
96
|
+
await honour([job({ runRequestedAt: ago(30_000) })]);
|
|
97
|
+
expect(dispatched).toHaveLength(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("ignores a request older than 15 minutes and logs expired once", async () => {
|
|
101
|
+
const { honour, dispatched, logs } = setup();
|
|
102
|
+
const j = job({ runRequestedAt: ago(RUN_NOW_FRESH_MS + 1) });
|
|
103
|
+
await honour([j]);
|
|
104
|
+
await honour([j]);
|
|
105
|
+
expect(dispatched).toEqual([]);
|
|
106
|
+
const expired = logs.filter((l) => l.includes("expired"));
|
|
107
|
+
expect(expired).toHaveLength(1);
|
|
108
|
+
expect(logs.join("\n")).not.toContain("stored prompt");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("a request exactly 15 minutes old is still fresh", async () => {
|
|
112
|
+
const { honour, dispatched } = setup();
|
|
113
|
+
await honour([job({ runRequestedAt: ago(RUN_NOW_FRESH_MS) })]);
|
|
114
|
+
expect(dispatched).toHaveLength(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("a request that ages out between ticks without being seen expires", async () => {
|
|
118
|
+
const { honour, dispatched, logs, advance } = setup();
|
|
119
|
+
const j = job({ runRequestedAt: ago(0) });
|
|
120
|
+
advance(RUN_NOW_FRESH_MS + 60_000);
|
|
121
|
+
await honour([j]);
|
|
122
|
+
expect(dispatched).toEqual([]);
|
|
123
|
+
expect(logs.some((l) => l.includes("expired"))).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("skips jobs with no request", async () => {
|
|
127
|
+
const { honour, dispatched, logs } = setup();
|
|
128
|
+
await honour([job(), job({ id: "job-2" })]);
|
|
129
|
+
expect(dispatched).toEqual([]);
|
|
130
|
+
expect(logs).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("a failing dispatch does not stop the other jobs and is not retried", async () => {
|
|
134
|
+
const dispatched: string[] = [];
|
|
135
|
+
const failures: string[] = [];
|
|
136
|
+
const honour = createRunNowHonourer({
|
|
137
|
+
clock: { now: () => NOW },
|
|
138
|
+
dispatch: async (req) => {
|
|
139
|
+
dispatched.push(req.jobId);
|
|
140
|
+
if (req.jobId === "bad") throw new Error("boom");
|
|
141
|
+
},
|
|
142
|
+
onError: (jobId) => {
|
|
143
|
+
failures.push(jobId);
|
|
144
|
+
},
|
|
145
|
+
log: () => {},
|
|
146
|
+
});
|
|
147
|
+
const jobs = [
|
|
148
|
+
job({ id: "bad", runRequestedAt: ago(1000) }),
|
|
149
|
+
job({ id: "good", runRequestedAt: ago(1000) }),
|
|
150
|
+
];
|
|
151
|
+
await honour(jobs);
|
|
152
|
+
await honour(jobs);
|
|
153
|
+
expect(dispatched).toEqual(["bad", "good"]);
|
|
154
|
+
expect(failures).toEqual(["bad"]);
|
|
155
|
+
});
|
|
156
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-vitals/agent-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/app-vitals/agent.git",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"./workspace-paths": "./workspace-paths.ts"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@app-vitals/agent-admin": "0.
|
|
44
|
-
"@app-vitals/agent-lib": "0.
|
|
43
|
+
"@app-vitals/agent-admin": "0.4.0",
|
|
44
|
+
"@app-vitals/agent-lib": "0.4.0",
|
|
45
45
|
"@octokit/auth-app": "^8.2.0",
|
|
46
46
|
"@sentry/bun": "^10.63.0",
|
|
47
47
|
"@slack/bolt": "^5.0.0",
|
|
@@ -53,6 +53,8 @@ const SAMPLE_CRONS: AgentCronJob[] = [
|
|
|
53
53
|
system: false,
|
|
54
54
|
parentCronId: null,
|
|
55
55
|
lockdownDisabledAt: null,
|
|
56
|
+
runRequestedAt: null,
|
|
57
|
+
runRequestedBy: null,
|
|
56
58
|
createdAt: new Date("2025-01-01T00:00:00Z"),
|
|
57
59
|
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
|
58
60
|
},
|