@app-vitals/agent-runtime 1.2.3 → 1.2.5
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/Dockerfile +39 -3
- package/base-image-contract.smoke.test.ts +40 -1
- package/boot.ts +41 -48
- package/cron-sync.ts +81 -0
- package/cron-sync.unit.test.ts +126 -0
- package/package.json +3 -3
package/Dockerfile
CHANGED
|
@@ -54,7 +54,10 @@ FROM oven/bun:1.4.4-slim@sha256:1846adf22496bd711036aae8be147e57bdebbaea2e5264e3
|
|
|
54
54
|
# package set as full `chromium` on this OS.
|
|
55
55
|
# oven/bun:1.4.4-slim is Debian trixie (13) — see node-source stage comment above
|
|
56
56
|
# — hence the t64-suffixed package names (Debian's 64-bit time_t transition).
|
|
57
|
-
|
|
57
|
+
# Upgrade apt/libapt-pkg7.0 past the base image's 3.0.3 (Trivy HIGH CVE-2026-109518, fixed in 3.0.4).
|
|
58
|
+
RUN apt-get update \
|
|
59
|
+
&& apt-get install -y --only-upgrade apt libapt-pkg7.0 \
|
|
60
|
+
&& apt-get install -y --no-install-recommends \
|
|
58
61
|
git \
|
|
59
62
|
curl \
|
|
60
63
|
ca-certificates \
|
|
@@ -129,13 +132,20 @@ RUN curl -fsSL -o /tmp/mise-install.sh https://mise.run \
|
|
|
129
132
|
# deb.nodesource.com (see that stage's comment).
|
|
130
133
|
COPY --from=node-source /usr/local/bin/node /usr/local/bin/node
|
|
131
134
|
COPY --from=node-source /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
|
|
135
|
+
# The npm copied from node-source bundles vulnerable undici/brace-expansion, so
|
|
136
|
+
# upgrade it in place before installing anything with it. npm 12 blocks install
|
|
137
|
+
# scripts by default, so Claude Code's postinstall (native binary) is allowed explicitly.
|
|
138
|
+
# renovate: datasource=npm depName=npm
|
|
139
|
+
ARG NPM_VERSION=12.2.2
|
|
132
140
|
# renovate: datasource=npm depName=@anthropic-ai/claude-code
|
|
133
141
|
ARG CLAUDE_CODE_VERSION=2.1.292
|
|
134
142
|
# The trailing check fails the build if the installed CLI doesn't report exactly
|
|
135
143
|
# the pinned version (e.g. a registry/tag mismatch), so a drifted image can't ship.
|
|
136
144
|
RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
|
137
145
|
&& ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
|
|
138
|
-
&& npm install -g
|
|
146
|
+
&& npm install -g npm@${NPM_VERSION} \
|
|
147
|
+
&& test "$(npm --version)" = "${NPM_VERSION}" \
|
|
148
|
+
&& npm install -g --allow-scripts=@anthropic-ai/claude-code @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
|
|
139
149
|
&& INSTALLED="$(claude --version)" \
|
|
140
150
|
&& echo "claude --version: ${INSTALLED}" \
|
|
141
151
|
&& case "${INSTALLED}" in \
|
|
@@ -150,6 +160,32 @@ RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
|
|
150
160
|
# from process.env, so spawned claude processes inherit it.
|
|
151
161
|
ENV DISABLE_AUTOUPDATER=1
|
|
152
162
|
|
|
163
|
+
# ─── Production dependencies ──────────────────────────────────────────────────
|
|
164
|
+
# Production-only install: the runtime stage copies node_modules from here, so
|
|
165
|
+
# devDependencies (semantic-release → handlebars, the TypeScript native `tsc`
|
|
166
|
+
# binary, a nested npm, biome) never ship in the image. Same manifests as the
|
|
167
|
+
# build stage; admin/prisma is needed because admin's postinstall runs
|
|
168
|
+
# `prisma generate`.
|
|
169
|
+
|
|
170
|
+
FROM base AS prod-deps
|
|
171
|
+
|
|
172
|
+
WORKDIR /app
|
|
173
|
+
|
|
174
|
+
COPY package.json bunfig.toml bun.lock* ./
|
|
175
|
+
COPY runtime/package.json ./runtime/
|
|
176
|
+
COPY admin/package.json ./admin/
|
|
177
|
+
COPY admin/prisma ./admin/prisma/
|
|
178
|
+
COPY admin/prisma.config.ts ./admin/
|
|
179
|
+
COPY lib/package.json ./lib/
|
|
180
|
+
COPY patches/ ./patches/
|
|
181
|
+
|
|
182
|
+
# typescript is only an optional peer of prisma / @prisma/client, but bun still
|
|
183
|
+
# links it (and its native tsc binary) into the store because the lockfile
|
|
184
|
+
# carries it; nothing at runtime loads it, so drop it.
|
|
185
|
+
RUN if [ -f bun.lock ]; then bun install --frozen-lockfile --production; else bun install --production; fi \
|
|
186
|
+
&& rm -rf node_modules/.bun/typescript@* node_modules/.bun/@typescript+* \
|
|
187
|
+
node_modules/.bun/node_modules/typescript node_modules/.bun/node_modules/@typescript
|
|
188
|
+
|
|
153
189
|
# ─── Build stage ──────────────────────────────────────────────────────────────
|
|
154
190
|
|
|
155
191
|
FROM base AS build
|
|
@@ -215,7 +251,7 @@ ENV HOME=/home/bun
|
|
|
215
251
|
# routes push through signed-push.
|
|
216
252
|
ENV PATH="/home/bun/.local/share/mise/shims:/app/scripts/bin:${PATH}"
|
|
217
253
|
|
|
218
|
-
COPY --from=
|
|
254
|
+
COPY --from=prod-deps --chown=1000:1000 /app/node_modules ./node_modules
|
|
219
255
|
COPY --from=build --chown=1000:1000 /app/runtime ./runtime
|
|
220
256
|
COPY --from=build --chown=1000:1000 /app/admin ./admin
|
|
221
257
|
COPY --from=build --chown=1000:1000 /app/lib ./lib
|
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
mkdirSync,
|
|
19
|
+
mkdtempSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from "node:fs";
|
|
18
24
|
import { tmpdir } from "node:os";
|
|
19
25
|
import { join } from "node:path";
|
|
20
26
|
|
|
@@ -146,4 +152,37 @@ describe.skipIf(!BASE_IMAGE)("agent base-image contract", () => {
|
|
|
146
152
|
"/app/scripts/bin/git",
|
|
147
153
|
]);
|
|
148
154
|
}, 60_000);
|
|
155
|
+
|
|
156
|
+
test("runtime image ships no devDependency-only packages and keeps the Prisma client", () => {
|
|
157
|
+
const run = docker([
|
|
158
|
+
"run",
|
|
159
|
+
"--rm",
|
|
160
|
+
"--entrypoint",
|
|
161
|
+
"bash",
|
|
162
|
+
DERIVED_TAG,
|
|
163
|
+
"-c",
|
|
164
|
+
'test -d /app/admin/prisma/client && test -z "$(find /app/node_modules/.bun -maxdepth 1 \\( -name "handlebars@*" -o -name "typescript@*" -o -name "@typescript+*" -o -name "semantic-release@*" -o -name "@biomejs+*" -o -name "npm@*" \\))"',
|
|
165
|
+
]);
|
|
166
|
+
expect(run.exitCode, run.stdout + run.stderr).toBe(0);
|
|
167
|
+
}, 60_000);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Static guard (no Docker needed): the runtime stage must take node_modules
|
|
171
|
+
// from the production-only stage, never the build stage's full install.
|
|
172
|
+
describe("runtime Dockerfile", () => {
|
|
173
|
+
const dockerfile = readFileSync(
|
|
174
|
+
join(import.meta.dir, "Dockerfile"),
|
|
175
|
+
"utf8",
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
test("copies node_modules from the production-only stage", () => {
|
|
179
|
+
expect(dockerfile).toMatch(
|
|
180
|
+
/^COPY --from=prod-deps\b.*\/app\/node_modules \.\/node_modules$/m,
|
|
181
|
+
);
|
|
182
|
+
expect(dockerfile).not.toMatch(
|
|
183
|
+
/^COPY --from=build\b.*\/app\/node_modules\b/m,
|
|
184
|
+
);
|
|
185
|
+
expect(dockerfile).toMatch(/^FROM base AS prod-deps$/m);
|
|
186
|
+
expect(dockerfile).toMatch(/bun install --frozen-lockfile --production/);
|
|
187
|
+
});
|
|
149
188
|
});
|
package/boot.ts
CHANGED
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
HttpCronRunReporter,
|
|
64
64
|
NoopCronRunReporter,
|
|
65
65
|
} from "./cron-run-reporter.ts";
|
|
66
|
+
import { reconcileCronTasks, type ScheduledEntry } from "./cron-sync.ts";
|
|
66
67
|
import { markdownToSlack } from "./format.ts";
|
|
67
68
|
import { buildGitHubAuthDeps } from "./github-auth-deps.ts";
|
|
68
69
|
import {
|
|
@@ -728,7 +729,10 @@ export async function startAgent(
|
|
|
728
729
|
|
|
729
730
|
// ─── Step 6: Cron sync loop ───────────────────────────────────────────────────
|
|
730
731
|
|
|
731
|
-
const cronTasks = new Map<
|
|
732
|
+
const cronTasks = new Map<
|
|
733
|
+
string,
|
|
734
|
+
ScheduledEntry<ReturnType<typeof nodeCron.schedule>>
|
|
735
|
+
>();
|
|
732
736
|
|
|
733
737
|
if (runtimeClient && agentId) {
|
|
734
738
|
// Run-now requests (COS-42.1): honoured at most once each, in memory.
|
|
@@ -799,7 +803,7 @@ export async function startAgent(
|
|
|
799
803
|
// kind (loop vs. generic). See planCronSchedule in agent-extension.ts and
|
|
800
804
|
// the coding extension's loop-cron-classifier.ts for the full decision.
|
|
801
805
|
const scheduled = planCronSchedule(extension, jobs);
|
|
802
|
-
const desired = new
|
|
806
|
+
const desired = new Set(scheduled.map((entry) => entry.job.id));
|
|
803
807
|
dispatchKinds = new Map(
|
|
804
808
|
scheduled.map((entry) => [entry.job.id, entry.dispatch]),
|
|
805
809
|
);
|
|
@@ -808,54 +812,43 @@ export async function startAgent(
|
|
|
808
812
|
// Only scheduled (enabled) jobs: a manually disabled cron is not run.
|
|
809
813
|
void honourRunNow(jobs.filter((job) => desired.has(job.id)));
|
|
810
814
|
|
|
811
|
-
//
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
const { job, dispatch } = entry;
|
|
824
|
-
const task = nodeCron.schedule(job.schedule, async () => {
|
|
825
|
-
console.log(`[cron] firing job ${id}`);
|
|
826
|
-
try {
|
|
827
|
-
if (dispatch === "loop") {
|
|
828
|
-
if (!extension.dispatchLoop) {
|
|
829
|
-
throw new Error(
|
|
830
|
-
`cron job ${id} classified as loop but the agent extension has no dispatchLoop`,
|
|
831
|
-
);
|
|
832
|
-
}
|
|
833
|
-
await extension.dispatchLoop(id);
|
|
834
|
-
} else {
|
|
835
|
-
await handleCronRequest(
|
|
836
|
-
{
|
|
837
|
-
jobId: id,
|
|
838
|
-
prompt: job.prompt,
|
|
839
|
-
channel: job.channel ?? undefined,
|
|
840
|
-
user: job.user ?? undefined,
|
|
841
|
-
silent: job.silent,
|
|
842
|
-
preCheck: job.preCheck ?? undefined,
|
|
843
|
-
},
|
|
844
|
-
cronDeps,
|
|
815
|
+
// Unschedule removed/disabled jobs, schedule new ones, and reschedule any
|
|
816
|
+
// job whose schedule, prompt, channel, user, silent, preCheck or dispatch
|
|
817
|
+
// kind changed since the last tick.
|
|
818
|
+
reconcileCronTasks(cronTasks, scheduled, (job, dispatch) => {
|
|
819
|
+
const id = job.id;
|
|
820
|
+
return nodeCron.schedule(job.schedule, async () => {
|
|
821
|
+
console.log(`[cron] firing job ${id}`);
|
|
822
|
+
try {
|
|
823
|
+
if (dispatch === "loop") {
|
|
824
|
+
if (!extension.dispatchLoop) {
|
|
825
|
+
throw new Error(
|
|
826
|
+
`cron job ${id} classified as loop but the agent extension has no dispatchLoop`,
|
|
845
827
|
);
|
|
846
828
|
}
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
829
|
+
await extension.dispatchLoop(id);
|
|
830
|
+
} else {
|
|
831
|
+
await handleCronRequest(
|
|
832
|
+
{
|
|
833
|
+
jobId: id,
|
|
834
|
+
prompt: job.prompt,
|
|
835
|
+
channel: job.channel ?? undefined,
|
|
836
|
+
user: job.user ?? undefined,
|
|
837
|
+
silent: job.silent,
|
|
838
|
+
preCheck: job.preCheck ?? undefined,
|
|
839
|
+
},
|
|
840
|
+
cronDeps,
|
|
841
|
+
);
|
|
853
842
|
}
|
|
854
|
-
})
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
843
|
+
} catch (err) {
|
|
844
|
+
await reportCronFailure(id, err, {
|
|
845
|
+
cronRunReporter: cronRunReporter ?? new NoopCronRunReporter(),
|
|
846
|
+
sentryClient: process.env.SENTRY_DSN ? Sentry : undefined,
|
|
847
|
+
clock: SystemClock(),
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
});
|
|
859
852
|
}
|
|
860
853
|
|
|
861
854
|
void syncCrons();
|
|
@@ -940,7 +933,7 @@ export async function startAgent(
|
|
|
940
933
|
markSlackDisconnected(slackClock);
|
|
941
934
|
|
|
942
935
|
// Stop cron tasks so no new Claude work fires during drain
|
|
943
|
-
for (const [id, task] of cronTasks) {
|
|
936
|
+
for (const [id, { task }] of cronTasks) {
|
|
944
937
|
task.stop();
|
|
945
938
|
console.log(`[agent] cron unscheduled on shutdown: ${id}`);
|
|
946
939
|
}
|
package/cron-sync.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime/cron-sync.ts
|
|
3
|
+
* Reconciles scheduled cron tasks against the desired job list on each sync
|
|
4
|
+
* tick. A job whose schedule, prompt, channel, user, silent, preCheck or
|
|
5
|
+
* dispatch kind changed is stopped and rescheduled; unchanged jobs are left
|
|
6
|
+
* alone. The scheduler is injected so the logic is testable without node-cron.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
CronDispatchKind,
|
|
11
|
+
ExtensionCronJob,
|
|
12
|
+
ScheduledCronJob,
|
|
13
|
+
} from "./agent-extension.ts";
|
|
14
|
+
|
|
15
|
+
export interface SyncableCronJob extends ExtensionCronJob {
|
|
16
|
+
schedule: string;
|
|
17
|
+
prompt: string;
|
|
18
|
+
channel: string | null;
|
|
19
|
+
user: string | null;
|
|
20
|
+
silent: boolean;
|
|
21
|
+
preCheck: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface StoppableTask {
|
|
25
|
+
stop(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ScheduledEntry<TTask extends StoppableTask> {
|
|
29
|
+
task: TTask;
|
|
30
|
+
signature: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Everything that, when edited, must take effect on the next sync tick. */
|
|
34
|
+
export function cronSignature(
|
|
35
|
+
job: SyncableCronJob,
|
|
36
|
+
dispatch: CronDispatchKind,
|
|
37
|
+
): string {
|
|
38
|
+
return JSON.stringify([
|
|
39
|
+
job.schedule,
|
|
40
|
+
job.prompt,
|
|
41
|
+
job.channel,
|
|
42
|
+
job.user,
|
|
43
|
+
job.silent,
|
|
44
|
+
job.preCheck,
|
|
45
|
+
dispatch,
|
|
46
|
+
]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function reconcileCronTasks<
|
|
50
|
+
TJob extends SyncableCronJob,
|
|
51
|
+
TTask extends StoppableTask,
|
|
52
|
+
>(
|
|
53
|
+
scheduled: Map<string, ScheduledEntry<TTask>>,
|
|
54
|
+
desired: ScheduledCronJob<TJob>[],
|
|
55
|
+
schedule: (job: TJob, dispatch: CronDispatchKind) => TTask,
|
|
56
|
+
log: (message: string) => void = console.log,
|
|
57
|
+
): void {
|
|
58
|
+
const desiredIds = new Set(desired.map((entry) => entry.job.id));
|
|
59
|
+
|
|
60
|
+
for (const [id, existing] of scheduled) {
|
|
61
|
+
if (!desiredIds.has(id)) {
|
|
62
|
+
existing.task.stop();
|
|
63
|
+
scheduled.delete(id);
|
|
64
|
+
log(`[cron-sync] unscheduled ${id}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const { job, dispatch } of desired) {
|
|
69
|
+
const signature = cronSignature(job, dispatch);
|
|
70
|
+
const existing = scheduled.get(job.id);
|
|
71
|
+
if (existing?.signature === signature) continue;
|
|
72
|
+
|
|
73
|
+
existing?.task.stop();
|
|
74
|
+
scheduled.set(job.id, { task: schedule(job, dispatch), signature });
|
|
75
|
+
log(
|
|
76
|
+
existing
|
|
77
|
+
? `[cron-sync] rescheduled ${job.id} (${job.schedule})`
|
|
78
|
+
: `[cron-sync] scheduled ${job.id} (${job.schedule})`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime/cron-sync.unit.test.ts
|
|
3
|
+
* Reconcile logic for the 60s cron sync tick, with a fake scheduler.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "bun:test";
|
|
7
|
+
import type { CronDispatchKind } from "./agent-extension.ts";
|
|
8
|
+
import {
|
|
9
|
+
reconcileCronTasks,
|
|
10
|
+
type ScheduledEntry,
|
|
11
|
+
type SyncableCronJob,
|
|
12
|
+
} from "./cron-sync.ts";
|
|
13
|
+
|
|
14
|
+
function job(over: Partial<SyncableCronJob> = {}): SyncableCronJob {
|
|
15
|
+
return {
|
|
16
|
+
id: "job-1",
|
|
17
|
+
name: "Job",
|
|
18
|
+
enabled: true,
|
|
19
|
+
parentCronId: null,
|
|
20
|
+
schedule: "0 * * * *",
|
|
21
|
+
prompt: "do it",
|
|
22
|
+
channel: "C1",
|
|
23
|
+
user: "U1",
|
|
24
|
+
silent: false,
|
|
25
|
+
preCheck: null,
|
|
26
|
+
...over,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function harness() {
|
|
31
|
+
const created: { id: string; schedule: string; stopped: boolean }[] = [];
|
|
32
|
+
const logs: string[] = [];
|
|
33
|
+
const scheduled = new Map<string, ScheduledEntry<{ stop(): void }>>();
|
|
34
|
+
const schedule = (j: SyncableCronJob) => {
|
|
35
|
+
const rec = { id: j.id, schedule: j.schedule, stopped: false };
|
|
36
|
+
created.push(rec);
|
|
37
|
+
return {
|
|
38
|
+
stop() {
|
|
39
|
+
rec.stopped = true;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
const tick = (
|
|
44
|
+
jobs: SyncableCronJob[],
|
|
45
|
+
dispatch: CronDispatchKind = "generic",
|
|
46
|
+
) =>
|
|
47
|
+
reconcileCronTasks(
|
|
48
|
+
scheduled,
|
|
49
|
+
jobs.map((j) => ({ job: j, dispatch })),
|
|
50
|
+
schedule,
|
|
51
|
+
(m) => logs.push(m),
|
|
52
|
+
);
|
|
53
|
+
return { created, logs, scheduled, tick };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("reconcileCronTasks", () => {
|
|
57
|
+
it("schedules a new job", () => {
|
|
58
|
+
const h = harness();
|
|
59
|
+
h.tick([job()]);
|
|
60
|
+
expect(h.created).toHaveLength(1);
|
|
61
|
+
expect(h.logs).toEqual(["[cron-sync] scheduled job-1 (0 * * * *)"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("does not reschedule an unchanged job", () => {
|
|
65
|
+
const h = harness();
|
|
66
|
+
h.tick([job()]);
|
|
67
|
+
h.tick([job()]);
|
|
68
|
+
expect(h.created).toHaveLength(1);
|
|
69
|
+
expect(h.created[0]?.stopped).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("reschedules when the schedule changes", () => {
|
|
73
|
+
const h = harness();
|
|
74
|
+
h.tick([job()]);
|
|
75
|
+
h.tick([job({ schedule: "*/5 * * * *" })]);
|
|
76
|
+
expect(h.created).toHaveLength(2);
|
|
77
|
+
expect(h.created[0]?.stopped).toBe(true);
|
|
78
|
+
expect(h.created[1]?.schedule).toBe("*/5 * * * *");
|
|
79
|
+
expect(h.logs.at(-1)).toContain("[cron-sync] rescheduled job-1");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("reschedules on a prompt-only change", () => {
|
|
83
|
+
const h = harness();
|
|
84
|
+
h.tick([job()]);
|
|
85
|
+
h.tick([job({ prompt: "new prompt" })]);
|
|
86
|
+
expect(h.created).toHaveLength(2);
|
|
87
|
+
expect(h.created[0]?.stopped).toBe(true);
|
|
88
|
+
expect(h.created[1]?.stopped).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("reschedules when channel, user, silent or preCheck change", () => {
|
|
92
|
+
const h = harness();
|
|
93
|
+
h.tick([job()]);
|
|
94
|
+
h.tick([job({ channel: "C2" })]);
|
|
95
|
+
h.tick([job({ channel: "C2", user: "U2" })]);
|
|
96
|
+
h.tick([job({ channel: "C2", user: "U2", silent: true })]);
|
|
97
|
+
h.tick([job({ channel: "C2", user: "U2", silent: true, preCheck: "x" })]);
|
|
98
|
+
expect(h.created).toHaveLength(5);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("reschedules when the dispatch kind changes", () => {
|
|
102
|
+
const h = harness();
|
|
103
|
+
h.tick([job()], "generic");
|
|
104
|
+
h.tick([job()], "loop");
|
|
105
|
+
expect(h.created).toHaveLength(2);
|
|
106
|
+
expect(h.created[0]?.stopped).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("unschedules a disabled job (absent from desired)", () => {
|
|
110
|
+
const h = harness();
|
|
111
|
+
h.tick([job()]);
|
|
112
|
+
h.tick([]);
|
|
113
|
+
expect(h.created[0]?.stopped).toBe(true);
|
|
114
|
+
expect(h.scheduled.size).toBe(0);
|
|
115
|
+
expect(h.logs.at(-1)).toBe("[cron-sync] unscheduled job-1");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("unschedules a deleted job without touching its siblings", () => {
|
|
119
|
+
const h = harness();
|
|
120
|
+
h.tick([job(), job({ id: "job-2" })]);
|
|
121
|
+
h.tick([job({ id: "job-2" })]);
|
|
122
|
+
expect(h.created.find((c) => c.id === "job-1")?.stopped).toBe(true);
|
|
123
|
+
expect(h.created.find((c) => c.id === "job-2")?.stopped).toBe(false);
|
|
124
|
+
expect([...h.scheduled.keys()]).toEqual(["job-2"]);
|
|
125
|
+
});
|
|
126
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-vitals/agent-runtime",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.5",
|
|
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": "1.2.
|
|
44
|
-
"@app-vitals/agent-lib": "1.2.
|
|
43
|
+
"@app-vitals/agent-admin": "1.2.5",
|
|
44
|
+
"@app-vitals/agent-lib": "1.2.5",
|
|
45
45
|
"@octokit/auth-app": "^8.2.0",
|
|
46
46
|
"@sentry/bun": "^10.63.0",
|
|
47
47
|
"@slack/bolt": "^5.0.0",
|