@terreno/jobs 57.20.0 → 57.22.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/CHANGELOG.md +81 -0
- package/biome.jsonc +1 -0
- package/e2e/smoke.ts +194 -0
- package/package.json +8 -6
- package/src/tests/gcpCloudTasksRunner.test.ts +1 -1
- package/src/tests/vercelQueuesRunner.test.ts +14 -4
- package/tsconfig.typecheck.json +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -20,6 +20,87 @@ Upgrade notes for consumer action live in [`mcp-server/src/docs/upgrades/`](mcp-
|
|
|
20
20
|
|
|
21
21
|
Unreleased changes live in [`changelog/unreleased/`](changelog/unreleased/). Add one Markdown file per feature (see that directory's README) instead of editing this section.
|
|
22
22
|
|
|
23
|
+
## [57.22.0] - 2026-11-03
|
|
24
|
+
|
|
25
|
+
Upgrade note: [`mcp-server/src/docs/upgrades/57.22.0.md`](mcp-server/src/docs/upgrades/57.22.0.md).
|
|
26
|
+
|
|
27
|
+
### Breaking
|
|
28
|
+
|
|
29
|
+
- `@terreno/ai` no longer integrates with Langfuse. The built-in observability prompt registry, traces, and review queue (`ObservabilityApp` with `createLocalObservabilityPlugin()`) replace it. Removed exports: `LangfuseApp`, `LangfuseCache`, `getCached`, `setCached`, `invalidateCache`, `initLangfuseClient`, `getLangfuseClient`, `isLangfuseInitialized`, `shutdownLangfuseClient`, `compilePrompt`, `createPrompt`, `getPrompt`, `invalidatePromptCache`, `initTracing`, `shutdownTracing`, `createTelemetryConfig`, `preparePromptForAI`, and the types `ChatMessage`, `GetPromptOptions`, `LangfuseAppOptions`, `LangfuseCachedPrompt`, `PaginatedResult`, `PreparePromptResult`, `PromptListItem`, `ScoreSubmission`, `ScoringFunction`, `TelemetrySettings`, `TraceListItem`. The `@terreno/ai/langfuseApp` and `@terreno/ai/langfuseClient` subpath exports are gone, along with the `@langfuse/client`, `@langfuse/otel`, and `@opentelemetry/*` dependencies of `@terreno/ai`.
|
|
30
|
+
- `addGptRoutes` drops `langfuseSystemPromptName`; use `systemPromptName` (a registry prompt) instead. Chat turns no longer set `experimental_telemetry`.
|
|
31
|
+
- `ControlPrimary` is now only `"local"`.
|
|
32
|
+
- The example backend no longer reads `LANGFUSE_SECRET_KEY`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_BASE_URL`, `LANGFUSE_ORGANIZATION`, `LANGFUSE_PROJECT`, or `LANGFUSE_PROJECT_ID`. Deploys (`.github/workflows/cd.yml`, `scripts/ci/gcp-deploy.sh`) no longer mount the Langfuse secrets, and `infra/flourish/main.tf` drops the `backend_secret_langfuse_secret_key` and `backend_secret_langfuse_public_key` modules, so the next `terraform apply` deletes those two Secret Manager secrets. Deploy a revision without the mounts before applying.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- `addGptRoutes` accepts `systemPromptName` and `systemPromptLabel`. A chat turn prepends that observability registry prompt to the system prompt and records its version on the trace.
|
|
37
|
+
- The example chat reads `chat-assistant` and the `generate_joke` tool reads `joke-generator` from the observability prompt registry.
|
|
38
|
+
- `bun run backend:seed` fills every AI Observability screen: labelled prompt versions for `example-summarize`, `joke-generator`, and `chat-assistant`, golden datasets, finished experiments, traces with scores, and review items in every status. Run it on an existing database with the `seedAiObservabilityDemo` admin script.
|
|
39
|
+
- Review batches: pick a human question set and press **Grab 10 recent traces** to give reviewers a fixed set of inputs and outputs (`POST /ai/observability/review/batches`).
|
|
40
|
+
- The review item screen shows one input, one output, and every question as Yes / No rows. On web, `Y` / `N` answer the highlighted question, `Enter` submits and moves to the next batch item, and `S` skips. Evaluator dimensions accept a reviewer `question`.
|
|
41
|
+
- Run any LLM judge over a batch and see overall and per-question agreement with the human answers, plus a list of disagreements (`POST /ai/observability/review/batches/:id/judgeRuns`).
|
|
42
|
+
- The AI observability seed adds the `summary-review-questions` question set, two judges for it, and a 10-item batch with 8 answers where one judge agrees 97% and the other 78%.
|
|
43
|
+
- Observability registry prompts can reference other prompts with `{{> base-rules}}`, `{{> base-rules label=staging}}`, or `{{> base-rules version=3}}` (default label `production`). Shared instructions live in one prompt; `PromptRegistry.get`, the playground, experiments, and judges use the resolved text.
|
|
44
|
+
- Every reference must resolve when it is saved. Creating a prompt, saving a version, and moving a label return 400 `Invalid prompt reference` for a malformed tag, an unknown prompt or version, or a label the referenced prompt does not have yet, 400 `Prompt reference cycle` when the change would make prompts include each other, and 400 `Prompt references nested too deeply` when it would nest the prompt, or any prompt that includes it, more than 5 levels.
|
|
45
|
+
- Traces stay named after the called prompt. `ObsTrace.prompts` lists it first, then each shared prompt version it pulled in, marked with `includedBy`. `PromptVersionRef.includes` and harness prompt refs carry the same list.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- Review queue: `Y` and `N` answer the highlighted question when a Yes/No segment is focused. **What the AI was given** and **What the AI wrote** are headings above the field cards.
|
|
50
|
+
- Review batches: **Save batch** sits on the New batch card. Search and **More filters** share a baseline.
|
|
51
|
+
- Prompt hub: description, folder, and tags sit on the Versions screen with the editor. System and template fields explain what to write.
|
|
52
|
+
- Evaluator detail loads the judge prompt `outputSchema` (so helpfulness no longer reports a missing dimension) and experiment rows open that experiment.
|
|
53
|
+
|
|
54
|
+
## [57.21.0] - 2026-11-01
|
|
55
|
+
|
|
56
|
+
Upgrade note: [`mcp-server/src/docs/upgrades/57.21.0.md`](mcp-server/src/docs/upgrades/57.21.0.md).
|
|
57
|
+
|
|
58
|
+
### Breaking
|
|
59
|
+
|
|
60
|
+
- The `terreno-scan` plugin is now `terreno-loop`, and its skills are renamed from `terreno-scan-*` to `terreno-loop-*`. The resident loop `terreno-scan-loop` is now `terreno-loop-run`: `/terreno-loop:run` in Claude Code, `/terreno-loop-run` in Cursor, `$terreno-loop-run` in Codex. Claude Code moves existing installs over through the marketplace `renames` entry. In Cursor and Codex, uninstall `terreno-scan` and install `terreno-loop`. Campaign state stays at `.terreno/scan/<goal>.json`, so campaigns already in progress resume.
|
|
61
|
+
|
|
62
|
+
### Added
|
|
63
|
+
|
|
64
|
+
- `@terreno/admin-spa` serves an MCP OAuth consent screen at `/console/oauth/consent`, so a backend with no product frontend can set `mcpOAuth.consentUrl` to `${issuer}/console/oauth/consent`. Any signed-in user can approve or deny a request with their Better Auth session; signed-out users sign in and return through the login screen's new `returnTo` parameter, which accepts only in-app paths.
|
|
65
|
+
- Claude Code plugins now publish to npm with every release, as `@terreno/agent-plugin` and `@terreno/agent-plugin-loop`. A new `terreno-releases` marketplace installs them at the latest stable release. To pin one release, add `TerrenoLabs/terreno#<version>` instead. See [Install agent skills](docs/how-to/install-agent-skills.md#pick-a-channel).
|
|
66
|
+
- The generated Claude plugin manifest no longer pins `version`. Before this change, plugins installed from the `TerrenoLabs/terreno` marketplace stayed on the `2.12.0` copy, because their version never changed. They now update on every commit. Run `/plugin update terreno@terreno-plugins` once to leave `2.12.0`.
|
|
67
|
+
- The `terreno-planning` lifecycle plugin (2.13.0) plans e2e tests and manual regression cases in every IP:
|
|
68
|
+
|
|
69
|
+
- Grow requires `## E2E tests` and `## Regression cases` sections (either may be `None — <reason>`), read from the e2e guide at `docs/testing/e2e.md` or a `## Testing conventions` override in AGENTS.md or CLAUDE.md.
|
|
70
|
+
- Regression cases stay minimal: most features add none. A case is only for what a developer or QA must check by hand, such as push notifications, and Roast fails one that repeats an existing case.
|
|
71
|
+
- Pick writes each planned spec in the task that delivers it, and Roast runs it; a missing or failing row is `FAIL`.
|
|
72
|
+
- Brew gates on every planned spec and merges regression cases into `docs/regression/<area>.md` with the next free ID.
|
|
73
|
+
- Taste re-checks both sections after behaviour-changing fixes and renumbers regression ID collisions with `master`.
|
|
74
|
+
- `bun run check:lifecycle-skills` enforces these phrases in every stage.
|
|
75
|
+
- New `backfill-regression` skill proposes manual regression cases one area at a time and writes only the sets the human approves.
|
|
76
|
+
- A consumer opts out of manual regression suites with `Regression suites | none` in its `## Testing conventions` table.
|
|
77
|
+
- The `terreno-planning` lifecycle plugin (2.14.0) grows an end-user guide with every feature:
|
|
78
|
+
|
|
79
|
+
- Every IP has a `## User guide` section listing the guide pages it adds, updates, or retires, or `None — <reason>`. Pages live in `docs/user-guide/<area>/<feature>.md`, one feature per page, with `title`, `audience`, `platforms`, and `questions` frontmatter that chat agents use to answer "how do I…" questions.
|
|
80
|
+
- Pick writes each page in the task that ships the feature, Roast checks it against the shipped screens, Brew gates on it and the index, and Taste keeps it current after fixes.
|
|
81
|
+
- A repo opts out with `| User guide | none — <reason> |` in a `## User guide conventions` table in AGENTS.md or CLAUDE.md.
|
|
82
|
+
- New `backfill-user-guide` skill drafts pages for features that shipped without one, one area at a time, and writes only the pages the human approves.
|
|
83
|
+
- `bun run check:user-guide` validates the pages and index (CI `repo-policies`); `--uncovered <app dir>` lists screens no page names.
|
|
84
|
+
- Terreno's own guide covers the example app's main features.
|
|
85
|
+
- `@terreno/api`: opt-in `mcpRequestLog` on `TerrenoApp` records one metadata-only `McpRequestLog` row per MCP tool call (tool, user, credential, service token or OAuth client, duration, outcome, error class; never arguments, results, or error text), expiring after `retentionDays` (default 30).
|
|
86
|
+
- `@terreno/api`: opt-in `mcpToolSettings` on `TerrenoApp` registers every MCP tool in `McpToolSetting` at startup; tools an admin switches off are hidden from `tools/list` and rejected by `tools/call`.
|
|
87
|
+
- `@terreno/api`: deleting an `McpOAuthToken` row revokes its whole connection (token family) and keeps the rows. New `McpOAuthToken.revokeAllForUser` and `McpServiceToken.revokeAllForUser`.
|
|
88
|
+
- `@terreno/admin-backend`: `mcpAdminModels()` adds MCP admin screens (service tokens, OAuth clients, OAuth connections, request log, tool switches), `mcpAdminStatements` for RBAC, and `mcpAdminScripts()` adds `mcp-sign-out-everywhere`. See [Manage MCP from the admin panel](docs/how-to/manage-mcp-in-admin.md).
|
|
89
|
+
- Docs: [Expose an MCP server safely](docs/how-to/expose-mcp-server-safely.md) (OAuth, service tokens, Cloudflare Tunnel, Access) and the `bootstrap-mcp-server` skill for a single-binary MCP server with the admin console embedded.
|
|
90
|
+
- Every workspace package has a real-process smoke test and an e2e page:
|
|
91
|
+
|
|
92
|
+
- Each package's `e2e/smoke.ts` (or `smoke.spec.ts` for browser packages) runs its built output through its public entry: a server on a real port, the compiled binary, or a page in Chromium. `bun run --cwd <package> smoke` runs one; `bun run smoke` runs them all and prints each package's time. The CircleCI `smoke` job runs them on every package change.
|
|
93
|
+
- `@terreno/test` adds the smoke helpers `smokeMongoUri()`, `spawnUntilReady()`, `spawnExampleBackend()`, `runBin()`, and `freePort()`.
|
|
94
|
+
- The [E2E testing guide](docs/testing/e2e.md) explains the test tiers and links one page per package under `docs/testing/e2e/`.
|
|
95
|
+
- Manual regression cases live in `docs/regression/<area>.md`, one file per area, and `bun run check:regression-suites` validates them.
|
|
96
|
+
- example-frontend's smoke serves a web export that `bun run --cwd example-frontend export:smoke` builds once with a placeholder API URL; the smoke swaps in a `freePort()` API URL as it serves the bundle, so the 30 s smoke no longer rebuilds the export. `spawnUntilReady()` takes `stopGraceMs`, and `spawnExampleBackend()` stops example-backend after 0.5 s instead of 5 s.
|
|
97
|
+
- Every workspace package has a `typecheck` script that runs `tsc` over source and test files through a new `tsconfig.typecheck.json`. Root `bun run lint` ends with `bun run typecheck`, and CircleCI runs it in a new `typecheck` job. Test files that had type errors were fixed.
|
|
98
|
+
|
|
99
|
+
### Changed
|
|
100
|
+
|
|
101
|
+
- `@terreno/api`: deleting an `McpOAuthToken` row with document or query `deleteOne` now revokes that row's whole token family (the connection) and keeps the rows instead of removing one pair. `deleteMany` is unchanged.
|
|
102
|
+
- `@terreno/ui`: new `theme.field` tokens (`background`, `backgroundDisabled`, `border`, `borderFocus`, `borderError`, `borderDisabled`) style form inputs. `TextField`, `SelectField`, `DateTimeField`, `HeightField`, and `OtpField` read them instead of `surface.base` and `border.dark`. Defaults equal the old colors, but overriding `surface` or `border` no longer recolors those fields; use `setTheme({field: ...})`.
|
|
103
|
+
|
|
23
104
|
## [57.20.0] - 2026-10-30
|
|
24
105
|
|
|
25
106
|
Upgrade note: [`mcp-server/src/docs/upgrades/57.20.0.md`](mcp-server/src/docs/upgrades/57.20.0.md).
|
package/biome.jsonc
CHANGED
package/e2e/smoke.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-process smoke for @terreno/jobs. Run `bun e2e/smoke.ts` after `bun run compile`.
|
|
3
|
+
*
|
|
4
|
+
* The smoke re-runs this file with `worker` as a child process: the compiled `@terreno/jobs`
|
|
5
|
+
* `JobsApp` with its default `MongoJobRunner` poll loop, against `smokeMongoUri()`, plus a
|
|
6
|
+
* readiness route on a free port. The parent is the enqueuing process: it registers its own
|
|
7
|
+
* `JobsApp` with the same job definition, enqueues one job, and checks that the worker process
|
|
8
|
+
* ran the handler and marked the job completed. Exits non-zero on the first failed check.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
freePort,
|
|
12
|
+
type SpawnedProcess,
|
|
13
|
+
smokeMongoUri,
|
|
14
|
+
spawnUntilReady,
|
|
15
|
+
stopSmokeMongo,
|
|
16
|
+
} from "@terreno/test";
|
|
17
|
+
import {DateTime} from "luxon";
|
|
18
|
+
|
|
19
|
+
const WORKER_ARG = "worker";
|
|
20
|
+
const JOB_NAME = "jobsSmoke/record";
|
|
21
|
+
const RUNS_COLLECTION = "jobsSmokeRuns";
|
|
22
|
+
const COMPLETION_TIMEOUT_MS = 15000;
|
|
23
|
+
const POLL_INTERVAL_MS = 100;
|
|
24
|
+
|
|
25
|
+
interface SmokePayload {
|
|
26
|
+
runId: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What the worker's handler writes, so the parent can see which process ran the job. */
|
|
30
|
+
interface SmokeRun {
|
|
31
|
+
jobId: string;
|
|
32
|
+
pid: number;
|
|
33
|
+
runId: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Child: a standalone worker, the shape of a dedicated jobs entrypoint. The handler records the
|
|
38
|
+
* job id and this process's pid. `/health` answers once the poll loop is running, and SIGTERM
|
|
39
|
+
* stops the worker before exiting. The packages are imported here, not at the top, so the
|
|
40
|
+
* parent's enqueue path loads them on its own.
|
|
41
|
+
*/
|
|
42
|
+
const work = async (): Promise<void> => {
|
|
43
|
+
const {logger} = await import("@terreno/api");
|
|
44
|
+
const {JobsApp} = await import("@terreno/jobs");
|
|
45
|
+
const {default: mongoose} = await import("mongoose");
|
|
46
|
+
|
|
47
|
+
await mongoose.connect(process.env.MONGO_URI as string);
|
|
48
|
+
|
|
49
|
+
const jobsApp = new JobsApp({pollIntervalMs: POLL_INTERVAL_MS});
|
|
50
|
+
jobsApp.define(JOB_NAME, {
|
|
51
|
+
handler: async (payload, ctx) => {
|
|
52
|
+
const run: SmokeRun = {
|
|
53
|
+
jobId: ctx.jobId,
|
|
54
|
+
pid: process.pid,
|
|
55
|
+
runId: (payload as SmokePayload).runId,
|
|
56
|
+
};
|
|
57
|
+
await mongoose.connection.collection(RUNS_COLLECTION).insertOne(run);
|
|
58
|
+
ctx.log.info(`[jobsSmoke] ran job ${ctx.jobId} in pid ${process.pid}`);
|
|
59
|
+
},
|
|
60
|
+
retry: {maxAttempts: 1},
|
|
61
|
+
});
|
|
62
|
+
await jobsApp.startWorker();
|
|
63
|
+
|
|
64
|
+
const server = Bun.serve({
|
|
65
|
+
fetch: () => {
|
|
66
|
+
const isWorkerActive = jobsApp.isWorkerActive();
|
|
67
|
+
return Response.json({worker: isWorkerActive}, {status: isWorkerActive ? 200 : 503});
|
|
68
|
+
},
|
|
69
|
+
hostname: "127.0.0.1",
|
|
70
|
+
port: Number(process.env.PORT),
|
|
71
|
+
});
|
|
72
|
+
process.once("SIGTERM", () => {
|
|
73
|
+
void (async (): Promise<void> => {
|
|
74
|
+
try {
|
|
75
|
+
await jobsApp.stopWorker();
|
|
76
|
+
await server.stop();
|
|
77
|
+
await mongoose.disconnect();
|
|
78
|
+
} finally {
|
|
79
|
+
process.exit(0);
|
|
80
|
+
}
|
|
81
|
+
})();
|
|
82
|
+
});
|
|
83
|
+
logger.info(`[jobsSmoke] worker polling, ready on port ${process.env.PORT}`);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Waits for the job to leave `pending`/`running`, and returns its final status. */
|
|
87
|
+
const waitForTerminalStatus = async ({
|
|
88
|
+
findStatus,
|
|
89
|
+
}: {
|
|
90
|
+
findStatus: () => Promise<string | undefined>;
|
|
91
|
+
}): Promise<string | undefined> => {
|
|
92
|
+
const deadline = DateTime.now().plus({milliseconds: COMPLETION_TIMEOUT_MS});
|
|
93
|
+
let status = await findStatus();
|
|
94
|
+
while (DateTime.now() < deadline && (status === "pending" || status === "running")) {
|
|
95
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
96
|
+
status = await findStatus();
|
|
97
|
+
}
|
|
98
|
+
return status;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Parent: boots the worker, enqueues one job from this process, and checks who ran it. */
|
|
102
|
+
const run = async (): Promise<void> => {
|
|
103
|
+
const startedAt = DateTime.now();
|
|
104
|
+
const runId = startedAt.toMillis();
|
|
105
|
+
const {setupLogging} = await import("@terreno/api");
|
|
106
|
+
const {JobsApp, Job, JobSchedule, getJobsService} = await import("@terreno/jobs");
|
|
107
|
+
const {default: express} = await import("express");
|
|
108
|
+
const {default: mongoose} = await import("mongoose");
|
|
109
|
+
|
|
110
|
+
const port = await freePort();
|
|
111
|
+
const mongoUri = await smokeMongoUri({databaseName: "jobsSmoke"});
|
|
112
|
+
let worker: SpawnedProcess | undefined;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
worker = await spawnUntilReady({
|
|
116
|
+
cmd: [process.execPath, __filename, WORKER_ARG],
|
|
117
|
+
env: {MONGO_URI: mongoUri, PORT: String(port)},
|
|
118
|
+
timeoutMs: 20000,
|
|
119
|
+
url: `http://127.0.0.1:${port}/health`,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Info level hides the admin routes' "no OpenAPI" debug lines from the bare Express app.
|
|
123
|
+
setupLogging({disableFileLogging: true, level: "info"});
|
|
124
|
+
// The enqueuing process shares the definition (for retry settings) but never starts a
|
|
125
|
+
// worker, so its handler running would mean the job did not cross processes.
|
|
126
|
+
await mongoose.connect(mongoUri);
|
|
127
|
+
const jobsApp = new JobsApp();
|
|
128
|
+
jobsApp.define(JOB_NAME, {
|
|
129
|
+
handler: async () => {
|
|
130
|
+
throw new Error("the enqueuing process must not run the job");
|
|
131
|
+
},
|
|
132
|
+
retry: {maxAttempts: 1},
|
|
133
|
+
});
|
|
134
|
+
jobsApp.register(express());
|
|
135
|
+
// register() builds indexes in the background; finish them before disconnecting.
|
|
136
|
+
await Promise.all([Job.init(), JobSchedule.init()]);
|
|
137
|
+
|
|
138
|
+
const job = await getJobsService().enqueue({name: JOB_NAME, payload: {runId}});
|
|
139
|
+
const jobId = job._id.toString();
|
|
140
|
+
if (job.status !== "pending") {
|
|
141
|
+
throw new Error(`enqueue: expected a pending job, got ${job.status}`);
|
|
142
|
+
}
|
|
143
|
+
console.info(`✅ enqueue persists pending job ${jobId} from pid ${process.pid}`);
|
|
144
|
+
|
|
145
|
+
const status = await waitForTerminalStatus({
|
|
146
|
+
findStatus: async () => (await Job.findOneOrNone({_id: job._id}))?.status,
|
|
147
|
+
});
|
|
148
|
+
if (status !== "completed") {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`worker: job ${jobId} is ${status ?? "missing"}, not completed, after ${COMPLETION_TIMEOUT_MS} ms`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
console.info(`✅ the worker process marks job ${jobId} completed`);
|
|
154
|
+
|
|
155
|
+
const runs = await mongoose.connection
|
|
156
|
+
.collection<SmokeRun>(RUNS_COLLECTION)
|
|
157
|
+
.find({jobId})
|
|
158
|
+
.toArray();
|
|
159
|
+
if (runs.length !== 1) {
|
|
160
|
+
throw new Error(`worker: expected the handler to run once, it ran ${runs.length} times`);
|
|
161
|
+
}
|
|
162
|
+
const [ran] = runs;
|
|
163
|
+
if (ran.pid !== worker.pid || ran.runId !== runId) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`worker: handler ran in pid ${ran.pid} with runId ${ran.runId}, expected worker pid ${worker.pid} and runId ${runId}`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
console.info(`✅ the handler ran once, in worker pid ${worker.pid}, with the enqueued payload`);
|
|
169
|
+
} catch (error: unknown) {
|
|
170
|
+
if (worker) {
|
|
171
|
+
console.error(`--- worker output ---\n${worker.output()}`);
|
|
172
|
+
}
|
|
173
|
+
throw error;
|
|
174
|
+
} finally {
|
|
175
|
+
await worker?.stop();
|
|
176
|
+
await mongoose.disconnect();
|
|
177
|
+
await stopSmokeMongo();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
console.info(`jobs smoke passed in ${DateTime.now().diff(startedAt).toMillis()} ms`);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const main = process.argv[2] === WORKER_ARG ? work : run;
|
|
184
|
+
main().then(
|
|
185
|
+
() => {
|
|
186
|
+
if (main === run) {
|
|
187
|
+
process.exit(0);
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
(error: unknown) => {
|
|
191
|
+
console.error(`❌ ${error instanceof Error ? error.message : String(error)}`);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
);
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"url": "https://github.com/TerrenoLabs/terreno/issues"
|
|
5
5
|
},
|
|
6
6
|
"dependencies": {
|
|
7
|
-
"@terreno/api": "57.
|
|
7
|
+
"@terreno/api": "57.22.0",
|
|
8
8
|
"cron": "^4.3.4",
|
|
9
9
|
"express": "^5.2.1",
|
|
10
10
|
"luxon": "^3.7.2"
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"description": "Durable background jobs plugin for @terreno/api — part of Terreno, the batteries-included TypeScript framework for universal apps.",
|
|
13
13
|
"devDependencies": {
|
|
14
14
|
"@biomejs/biome": "^2.3.6",
|
|
15
|
-
"@terreno/test": "57.
|
|
15
|
+
"@terreno/test": "57.22.0",
|
|
16
16
|
"@types/bun": "^1.4.0",
|
|
17
17
|
"@types/express": "^5.0.6",
|
|
18
18
|
"@types/luxon": "^3.7.1",
|
|
@@ -70,13 +70,15 @@
|
|
|
70
70
|
"compile": "bun tsc",
|
|
71
71
|
"compile:watch": "bun tsc -w",
|
|
72
72
|
"dev": "bun tsc -w",
|
|
73
|
-
"lint": "biome check ./src",
|
|
74
|
-
"lint:fix": "biome check --write ./src",
|
|
73
|
+
"lint": "biome check ./src ./e2e",
|
|
74
|
+
"lint:fix": "biome check --write ./src ./e2e",
|
|
75
|
+
"smoke": "bun e2e/smoke.ts",
|
|
75
76
|
"test": "bun test",
|
|
76
77
|
"test:agent": "AGENT=1 bun test --only-failures",
|
|
77
78
|
"test:ci": "bun test",
|
|
78
|
-
"test:coverage": "bun run ../scripts/check-coverage.ts"
|
|
79
|
+
"test:coverage": "bun run ../scripts/check-coverage.ts",
|
|
80
|
+
"typecheck": "tsc -p tsconfig.typecheck.json"
|
|
79
81
|
},
|
|
80
82
|
"types": "dist/index.d.ts",
|
|
81
|
-
"version": "57.
|
|
83
|
+
"version": "57.22.0"
|
|
82
84
|
}
|
|
@@ -141,7 +141,7 @@ describe("GcpCloudTasksRunner", () => {
|
|
|
141
141
|
const {client: _client, ...configWithoutClient} = baseConfig();
|
|
142
142
|
let isPeerAvailable = true;
|
|
143
143
|
try {
|
|
144
|
-
createRequire(
|
|
144
|
+
createRequire(__filename)("@google-cloud/tasks");
|
|
145
145
|
} catch {
|
|
146
146
|
isPeerAvailable = false;
|
|
147
147
|
}
|
|
@@ -35,6 +35,16 @@ import {
|
|
|
35
35
|
type VercelQueuesRunnerConfig,
|
|
36
36
|
} from "../runners/vercelQueues";
|
|
37
37
|
|
|
38
|
+
/** A fixed, valid instant; the runner helpers take `DateTime<true>`. */
|
|
39
|
+
const parseValidUtc = (iso: string): DateTime<true> => {
|
|
40
|
+
const parsed = DateTime.fromISO(iso, {zone: "utc"});
|
|
41
|
+
if (!parsed.isValid) {
|
|
42
|
+
throw new Error(`Invalid ISO timestamp: ${iso}`);
|
|
43
|
+
}
|
|
44
|
+
return parsed;
|
|
45
|
+
};
|
|
46
|
+
const FIXED_NOW = parseValidUtc("2026-06-01T12:00:00.000Z");
|
|
47
|
+
|
|
38
48
|
interface RecordedSendCall {
|
|
39
49
|
options?: VercelQueueSendOptions;
|
|
40
50
|
payload: unknown;
|
|
@@ -87,7 +97,7 @@ const createFakeHandleCallback = (): {
|
|
|
87
97
|
return new Response("ok", {status: 200});
|
|
88
98
|
} catch (error: unknown) {
|
|
89
99
|
const directive = options?.retry?.(error, sampleMetadata());
|
|
90
|
-
if (directive
|
|
100
|
+
if (directive && "acknowledge" in directive) {
|
|
91
101
|
return new Response("acknowledged", {status: 200});
|
|
92
102
|
}
|
|
93
103
|
if (directive && "afterSeconds" in directive) {
|
|
@@ -139,7 +149,7 @@ describe("VercelQueuesRunner", () => {
|
|
|
139
149
|
const runner = new VercelQueuesRunner(baseConfig());
|
|
140
150
|
|
|
141
151
|
assert.equal(runner.requiresExecuteRoute, false);
|
|
142
|
-
assert.isUndefined(runner
|
|
152
|
+
assert.isUndefined(Reflect.get(runner, "start"));
|
|
143
153
|
});
|
|
144
154
|
|
|
145
155
|
it("throws when topic is missing or blank", (): void => {
|
|
@@ -194,7 +204,7 @@ describe("VercelQueuesRunner", () => {
|
|
|
194
204
|
});
|
|
195
205
|
|
|
196
206
|
it("bumps retention to cover delaySeconds beyond 24h with a fixed now", (): void => {
|
|
197
|
-
const now =
|
|
207
|
+
const now = FIXED_NOW;
|
|
198
208
|
const runAt = now.plus({hours: 30});
|
|
199
209
|
const job = buildJob({runAt: runAt.toJSDate()});
|
|
200
210
|
const options = buildVercelQueueSendOptions(job, {}, now);
|
|
@@ -258,7 +268,7 @@ describe("VercelQueuesRunner", () => {
|
|
|
258
268
|
});
|
|
259
269
|
|
|
260
270
|
describe("computeVercelQueueRetryAfterSeconds", () => {
|
|
261
|
-
const now =
|
|
271
|
+
const now = FIXED_NOW;
|
|
262
272
|
|
|
263
273
|
it("clamps retry visibility to the vendor 30..3600 second range", (): void => {
|
|
264
274
|
assert.equal(
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"noEmit": true,
|
|
5
|
+
"composite": false,
|
|
6
|
+
"incremental": false,
|
|
7
|
+
"declaration": false,
|
|
8
|
+
"declarationMap": false,
|
|
9
|
+
"types": [
|
|
10
|
+
"node",
|
|
11
|
+
"bun"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"exclude": [
|
|
15
|
+
"node_modules",
|
|
16
|
+
"dist"
|
|
17
|
+
]
|
|
18
|
+
}
|