@danielblomma/cortex-mcp 2.0.5 → 2.0.7

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.
@@ -0,0 +1,215 @@
1
+ import { z } from "zod";
2
+ import { advanceStage, createRun, getRunState } from "./run-lifecycle.js";
3
+ import { composeStageEnvelope } from "./envelope.js";
4
+ import { DEFAULT_WORKFLOWS } from "./default-workflows.js";
5
+ import { loadSyncedWorkflows } from "./synced-registry.js";
6
+ import {
7
+ stageStatusSchema,
8
+ type StageStatus,
9
+ type WorkflowDefinition,
10
+ } from "./schemas.js";
11
+
12
+ /**
13
+ * Pure runner functions that back the cortex.workflow.* MCP tools.
14
+ * Kept separate from server.ts so they can be unit-tested without spinning
15
+ * up an MCP server. server.ts is a thin shim that registers each runner
16
+ * under its tool name and serializes the result through buildToolResult.
17
+ */
18
+
19
+ const slugSchema = z
20
+ .string()
21
+ .min(1)
22
+ .max(80)
23
+ .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/);
24
+
25
+ export const WorkflowStartInput = z.object({
26
+ task_id: slugSchema,
27
+ task_description: z.string().min(1).max(2000),
28
+ workflow_id: slugSchema.default("secure-build"),
29
+ });
30
+ export type WorkflowStartInputT = z.infer<typeof WorkflowStartInput>;
31
+
32
+ export const WorkflowAdvanceInput = z.object({
33
+ task_id: slugSchema,
34
+ /** Required for safety: must equal the run's current_stage. */
35
+ stage: slugSchema,
36
+ /**
37
+ * Stage frontmatter as a free-form object. Stage / status / references /
38
+ * written_at are managed by the harness and may be omitted (or, if set,
39
+ * are overridden). Stage-specific fields like `approved` or `score` are
40
+ * passed through.
41
+ */
42
+ frontmatter: z.record(z.string(), z.unknown()).default({}),
43
+ body: z.string().min(1),
44
+ /** Final stage status. Defaults to "complete". Use "blocked" or "failed" to halt the run. */
45
+ status: stageStatusSchema.optional(),
46
+ /** Optional structured outcome surfaced into state.json for fast lookup by later stages. */
47
+ outcome: z.record(z.string(), z.unknown()).optional(),
48
+ });
49
+ export type WorkflowAdvanceInputT = z.infer<typeof WorkflowAdvanceInput>;
50
+
51
+ export const WorkflowStatusInput = z.object({
52
+ task_id: slugSchema,
53
+ });
54
+ export type WorkflowStatusInputT = z.infer<typeof WorkflowStatusInput>;
55
+
56
+ export const WorkflowEnvelopeInput = z.object({
57
+ task_id: slugSchema,
58
+ /** Defaults to the run's current stage. */
59
+ stage: slugSchema.optional(),
60
+ });
61
+ export type WorkflowEnvelopeInputT = z.infer<typeof WorkflowEnvelopeInput>;
62
+
63
+ /**
64
+ * Resolves the project root. The MCP server is started with cwd =
65
+ * project root and CORTEX_PROJECT_ROOT set to the same value (see
66
+ * bin/cortex.mjs `mcp` command). Tests pass cwd explicitly.
67
+ */
68
+ export function resolveProjectRoot(): string {
69
+ const fromEnv = process.env.CORTEX_PROJECT_ROOT?.trim();
70
+ if (fromEnv) return fromEnv;
71
+ return process.cwd();
72
+ }
73
+
74
+ export type WorkflowToolContext = {
75
+ cwd: string;
76
+ workflows?: Record<string, WorkflowDefinition>;
77
+ };
78
+
79
+ function resolveWorkflow(
80
+ workflowId: string,
81
+ registry: Record<string, WorkflowDefinition> | undefined,
82
+ ): WorkflowDefinition {
83
+ // When the caller passes an explicit registry, it wins outright (used
84
+ // by tests). Otherwise we merge bundled defaults with the org-authored
85
+ // workflows the daemon has synced into ~/.cortex/workflows.local.json,
86
+ // with the synced ones taking precedence on workflow_id collisions so
87
+ // org overrides actually override.
88
+ const workflows =
89
+ registry ?? { ...DEFAULT_WORKFLOWS, ...loadSyncedWorkflows() };
90
+ const workflow = workflows[workflowId];
91
+ if (!workflow) {
92
+ throw new Error(
93
+ `Unknown workflow_id: ${workflowId}. Available: ${Object.keys(workflows).join(", ") || "<none>"}`,
94
+ );
95
+ }
96
+ return workflow;
97
+ }
98
+
99
+ export function runWorkflowStart(
100
+ input: WorkflowStartInputT,
101
+ ctx: WorkflowToolContext,
102
+ ) {
103
+ const workflow = resolveWorkflow(input.workflow_id, ctx.workflows);
104
+ const state = createRun({
105
+ cwd: ctx.cwd,
106
+ taskId: input.task_id,
107
+ workflow,
108
+ taskDescription: input.task_description,
109
+ });
110
+ const envelope = composeStageEnvelope({
111
+ cwd: ctx.cwd,
112
+ taskId: input.task_id,
113
+ workflow,
114
+ });
115
+ return {
116
+ state,
117
+ envelope,
118
+ };
119
+ }
120
+
121
+ export function runWorkflowAdvance(
122
+ input: WorkflowAdvanceInputT,
123
+ ctx: WorkflowToolContext,
124
+ ) {
125
+ const state = getRunState(ctx.cwd, input.task_id);
126
+ if (!state) {
127
+ throw new Error(
128
+ `No run state found for task ${input.task_id}. Call cortex.workflow.start first.`,
129
+ );
130
+ }
131
+ const workflow = resolveWorkflow(state.workflow_id, ctx.workflows);
132
+ const stage = workflow.stages.find((s) => s.name === input.stage);
133
+ if (!stage) {
134
+ throw new Error(`Stage ${input.stage} is not defined in workflow ${workflow.id}`);
135
+ }
136
+
137
+ const finalStatus: StageStatus = input.status ?? "complete";
138
+
139
+ const nextState = advanceStage({
140
+ cwd: ctx.cwd,
141
+ taskId: input.task_id,
142
+ workflow,
143
+ stageName: input.stage,
144
+ artifactName: stage.artifact,
145
+ frontmatter: {
146
+ ...input.frontmatter,
147
+ stage: input.stage,
148
+ status: finalStatus,
149
+ references:
150
+ (Array.isArray((input.frontmatter as Record<string, unknown>).references)
151
+ ? ((input.frontmatter as Record<string, unknown>).references as unknown[])
152
+ .filter((v): v is string => typeof v === "string")
153
+ : null) ?? deriveReferencesFromReads(stage.reads, workflow),
154
+ },
155
+ body: input.body,
156
+ outcome: input.outcome,
157
+ status: finalStatus,
158
+ });
159
+
160
+ // If the run is still going, also return the next envelope so the caller
161
+ // can immediately know what comes next without a follow-up status round-trip.
162
+ let nextEnvelope: ReturnType<typeof composeStageEnvelope> | null = null;
163
+ if (nextState.outcome === "in_progress" && nextState.current_stage) {
164
+ nextEnvelope = composeStageEnvelope({
165
+ cwd: ctx.cwd,
166
+ taskId: input.task_id,
167
+ workflow,
168
+ });
169
+ }
170
+
171
+ return {
172
+ state: nextState,
173
+ next_envelope: nextEnvelope,
174
+ };
175
+ }
176
+
177
+ function deriveReferencesFromReads(
178
+ reads: string[],
179
+ workflow: WorkflowDefinition,
180
+ ): string[] {
181
+ const refs: string[] = [];
182
+ for (const readName of reads) {
183
+ const stage = workflow.stages.find((s) => s.name === readName);
184
+ if (stage) refs.push(stage.artifact);
185
+ }
186
+ return refs;
187
+ }
188
+
189
+ export function runWorkflowStatus(
190
+ input: WorkflowStatusInputT,
191
+ ctx: WorkflowToolContext,
192
+ ) {
193
+ const state = getRunState(ctx.cwd, input.task_id);
194
+ return { state };
195
+ }
196
+
197
+ export function runWorkflowEnvelope(
198
+ input: WorkflowEnvelopeInputT,
199
+ ctx: WorkflowToolContext,
200
+ ) {
201
+ const state = getRunState(ctx.cwd, input.task_id);
202
+ if (!state) {
203
+ throw new Error(
204
+ `No run state found for task ${input.task_id}. Call cortex.workflow.start first.`,
205
+ );
206
+ }
207
+ const workflow = resolveWorkflow(state.workflow_id, ctx.workflows);
208
+ const envelope = composeStageEnvelope({
209
+ cwd: ctx.cwd,
210
+ taskId: input.task_id,
211
+ workflow,
212
+ stageName: input.stage,
213
+ });
214
+ return { envelope };
215
+ }
@@ -0,0 +1,165 @@
1
+ import {
2
+ readRunState,
3
+ writeRunState,
4
+ writeStageArtifact,
5
+ } from "./artifact-io.js";
6
+ import {
7
+ runStateSchema,
8
+ stageArtifactFrontmatterSchema,
9
+ workflowDefinitionSchema,
10
+ type RunState,
11
+ type StageRecord,
12
+ type StageStatus,
13
+ type WorkflowDefinition,
14
+ } from "./schemas.js";
15
+
16
+ /**
17
+ * Lifecycle helpers for one workflow run. The harness composes envelopes
18
+ * and invokes agents elsewhere; these primitives only manipulate the
19
+ * persisted state under .agents/<task-id>/. Pure functions on top of
20
+ * artifact-io.ts so unit tests can hit them without spawning agents.
21
+ */
22
+
23
+ export type CreateRunOptions = {
24
+ cwd: string;
25
+ taskId: string;
26
+ workflow: WorkflowDefinition;
27
+ taskDescription: string;
28
+ now?: () => Date;
29
+ };
30
+
31
+ export function createRun(options: CreateRunOptions): RunState {
32
+ const workflow = workflowDefinitionSchema.parse(options.workflow);
33
+ const now = (options.now ?? (() => new Date()))();
34
+ const startedAt = now.toISOString();
35
+
36
+ const stages: StageRecord[] = workflow.stages.map((stage) => ({
37
+ name: stage.name,
38
+ status: "pending" as StageStatus,
39
+ }));
40
+
41
+ const state: RunState = {
42
+ schema_version: 1,
43
+ task_id: options.taskId,
44
+ workflow_id: workflow.id,
45
+ workflow_version: workflow.version,
46
+ task_description: options.taskDescription,
47
+ current_stage: workflow.stages[0].name,
48
+ outcome: "in_progress",
49
+ started_at: startedAt,
50
+ completed_at: null,
51
+ stages,
52
+ };
53
+
54
+ // Validate before write so a malformed input never reaches disk.
55
+ const validated = runStateSchema.parse(state);
56
+ writeRunState(options.cwd, validated);
57
+ return validated;
58
+ }
59
+
60
+ export function getRunState(cwd: string, taskId: string): RunState | null {
61
+ return readRunState(cwd, taskId);
62
+ }
63
+
64
+ export type AdvanceStageOptions = {
65
+ cwd: string;
66
+ taskId: string;
67
+ workflow: WorkflowDefinition;
68
+ /** The stage we just finished. Must equal state.current_stage. */
69
+ stageName: string;
70
+ /** Filename of the artifact to write (e.g. "plan.md"). */
71
+ artifactName: string;
72
+ /** Frontmatter for the artifact, minus the auto-injected `written_at`. */
73
+ frontmatter: Omit<
74
+ import("./schemas.js").StageArtifactFrontmatter,
75
+ "written_at"
76
+ > & { written_at?: string };
77
+ /** Markdown body of the artifact. */
78
+ body: string;
79
+ /** Per-stage outcome surfaced into state.json for fast lookup. */
80
+ outcome?: Record<string, unknown>;
81
+ /** Final status to record for this stage. Defaults to "complete". */
82
+ status?: StageStatus;
83
+ now?: () => Date;
84
+ };
85
+
86
+ /**
87
+ * Marks `stageName` as finished, writes its artifact under .agents/<task-id>/,
88
+ * and advances `current_stage` to the next stage (or marks the run complete
89
+ * if this was the final stage). Idempotent only at the artifact layer —
90
+ * calling twice for the same stage will overwrite the artifact and the
91
+ * state.json record.
92
+ */
93
+ export function advanceStage(options: AdvanceStageOptions): RunState {
94
+ const workflow = workflowDefinitionSchema.parse(options.workflow);
95
+ const state = readRunState(options.cwd, options.taskId);
96
+ if (!state) {
97
+ throw new Error(
98
+ `No run state found for task ${options.taskId}. Call createRun() first.`,
99
+ );
100
+ }
101
+ if (state.workflow_id !== workflow.id) {
102
+ throw new Error(
103
+ `Workflow mismatch: run was started with ${state.workflow_id}, advance was called with ${workflow.id}`,
104
+ );
105
+ }
106
+ if (state.current_stage !== options.stageName) {
107
+ throw new Error(
108
+ `Cannot advance stage ${options.stageName}: run is currently at ${
109
+ state.current_stage ?? "<finished>"
110
+ }`,
111
+ );
112
+ }
113
+
114
+ const now = (options.now ?? (() => new Date()))();
115
+ const completedAt = now.toISOString();
116
+
117
+ const frontmatter = stageArtifactFrontmatterSchema.parse({
118
+ ...options.frontmatter,
119
+ stage: options.stageName,
120
+ written_at: options.frontmatter.written_at ?? completedAt,
121
+ });
122
+ writeStageArtifact(
123
+ options.cwd,
124
+ options.taskId,
125
+ options.artifactName,
126
+ frontmatter,
127
+ options.body,
128
+ );
129
+
130
+ const stageIndex = workflow.stages.findIndex((s) => s.name === options.stageName);
131
+ const nextStage = workflow.stages[stageIndex + 1] ?? null;
132
+ const finalStatus = options.status ?? "complete";
133
+
134
+ const updatedStages: StageRecord[] = state.stages.map((record) => {
135
+ if (record.name !== options.stageName) return record;
136
+ return {
137
+ ...record,
138
+ status: finalStatus,
139
+ artifact: options.artifactName,
140
+ started_at: record.started_at ?? state.started_at,
141
+ completed_at: completedAt,
142
+ outcome: options.outcome,
143
+ };
144
+ });
145
+
146
+ const runOutcome: RunState["outcome"] =
147
+ finalStatus === "blocked" || finalStatus === "failed"
148
+ ? finalStatus
149
+ : nextStage
150
+ ? "in_progress"
151
+ : "complete";
152
+
153
+ const next: RunState = {
154
+ ...state,
155
+ current_stage:
156
+ runOutcome === "in_progress" && nextStage ? nextStage.name : null,
157
+ outcome: runOutcome,
158
+ completed_at: runOutcome === "in_progress" ? null : completedAt,
159
+ stages: updatedStages,
160
+ };
161
+
162
+ const validated = runStateSchema.parse(next);
163
+ writeRunState(options.cwd, validated);
164
+ return validated;
165
+ }
@@ -0,0 +1,125 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Schemas for the Cortex Harness workflow engine.
5
+ *
6
+ * See docs/harness-vision.md for the design. In short:
7
+ *
8
+ * .agents/<task-id>/
9
+ * plan.md # frontmatter + body
10
+ * review.md
11
+ * changes.md
12
+ * mutation-report.md
13
+ * security-report.md
14
+ * state.json # current run state
15
+ *
16
+ * All artifacts are markdown with YAML frontmatter; state.json is the only
17
+ * JSON file. Both are tracked in git so a PR carries the evidence trail.
18
+ */
19
+
20
+ const slugSchema = z
21
+ .string()
22
+ .min(1)
23
+ .max(80)
24
+ .regex(
25
+ /^[a-z0-9][a-z0-9-]*[a-z0-9]$/,
26
+ "Must be lowercase alphanumeric with hyphens (no leading/trailing hyphen)",
27
+ );
28
+
29
+ /**
30
+ * Static definition of a single stage in a workflow. Authored at the
31
+ * organization level (in cortex-web later) and synced down to projects.
32
+ */
33
+ export const stageDefinitionSchema = z.object({
34
+ name: slugSchema,
35
+ artifact: z.string().min(1).regex(/^[a-z0-9][a-z0-9-]*\.md$/),
36
+ /** Stage names this stage may read artifacts from. Empty = no inputs. */
37
+ reads: z.array(slugSchema).default([]),
38
+ /** Required frontmatter fields the produced artifact must populate. */
39
+ required_fields: z.array(z.string().min(1)).default([]),
40
+ /** Capability key the stage runs under. References a separate capability registry. */
41
+ capability: z.string().min(1).optional(),
42
+ /** Human-readable summary surfaced in dashboards and audit. */
43
+ description: z.string().min(1).max(500),
44
+ });
45
+
46
+ export type StageDefinition = z.infer<typeof stageDefinitionSchema>;
47
+
48
+ /**
49
+ * A complete workflow: ordered stages plus a stable identifier.
50
+ */
51
+ export const workflowDefinitionSchema = z.object({
52
+ id: slugSchema,
53
+ description: z.string().min(1).max(500),
54
+ version: z.number().int().min(1),
55
+ stages: z.array(stageDefinitionSchema).min(1),
56
+ });
57
+
58
+ export type WorkflowDefinition = z.infer<typeof workflowDefinitionSchema>;
59
+
60
+ /**
61
+ * Status of a single stage inside a run.
62
+ */
63
+ export const stageStatusSchema = z.enum([
64
+ "pending",
65
+ "in_progress",
66
+ "complete",
67
+ "blocked",
68
+ "failed",
69
+ ]);
70
+
71
+ export type StageStatus = z.infer<typeof stageStatusSchema>;
72
+
73
+ /**
74
+ * Per-stage record inside state.json. Holds outcome metadata that the next
75
+ * stage's envelope composer needs without re-parsing every artifact.
76
+ */
77
+ export const stageRecordSchema = z.object({
78
+ name: slugSchema,
79
+ status: stageStatusSchema,
80
+ artifact: z.string().min(1).optional(),
81
+ started_at: z.string().datetime().optional(),
82
+ completed_at: z.string().datetime().optional(),
83
+ /** Frontmatter outcome surfaced for fast lookup (e.g. approved=true on review). */
84
+ outcome: z.record(z.string(), z.unknown()).optional(),
85
+ });
86
+
87
+ export type StageRecord = z.infer<typeof stageRecordSchema>;
88
+
89
+ /**
90
+ * The full state of one workflow run, persisted as
91
+ * .agents/<task-id>/state.json. Written only on stage boundaries so it
92
+ * never churns mid-tick.
93
+ */
94
+ export const runStateSchema = z.object({
95
+ schema_version: z.literal(1),
96
+ task_id: slugSchema,
97
+ workflow_id: slugSchema,
98
+ workflow_version: z.number().int().min(1),
99
+ task_description: z.string().min(1).max(2000),
100
+ current_stage: slugSchema.nullable(),
101
+ outcome: z.enum(["in_progress", "complete", "failed", "blocked"]),
102
+ started_at: z.string().datetime(),
103
+ completed_at: z.string().datetime().nullable(),
104
+ stages: z.array(stageRecordSchema).min(1),
105
+ });
106
+
107
+ export type RunState = z.infer<typeof runStateSchema>;
108
+
109
+ /**
110
+ * The required-by-convention frontmatter shape every stage artifact carries.
111
+ * Stages may add additional structured fields; these four are the ones the
112
+ * harness itself relies on.
113
+ */
114
+ export const stageArtifactFrontmatterSchema = z
115
+ .object({
116
+ stage: slugSchema,
117
+ status: stageStatusSchema,
118
+ /** Sister-artifacts this artifact references (relative filenames). */
119
+ references: z.array(z.string().min(1)).default([]),
120
+ /** ISO 8601; injected by the harness, not the agent. */
121
+ written_at: z.string().datetime(),
122
+ })
123
+ .passthrough();
124
+
125
+ export type StageArtifactFrontmatter = z.infer<typeof stageArtifactFrontmatterSchema>;
@@ -0,0 +1,64 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { workflowDefinitionSchema, type WorkflowDefinition } from "./schemas.js";
5
+
6
+ /**
7
+ * Read side of the org-workflow sync cache. The daemon's
8
+ * workflow-sync-checker writes ~/.cortex/workflows.local.json; this
9
+ * module reads it. Kept in core/workflow/ rather than daemon/ so
10
+ * mcp-tools.ts can consult the cache without depending on daemon code.
11
+ *
12
+ * Each entry is validated against workflowDefinitionSchema before being
13
+ * surfaced — if the cache file is corrupt or contains stale shapes from
14
+ * an older daemon, those entries are silently dropped rather than
15
+ * crashing the read.
16
+ */
17
+
18
+ export const SYNCED_WORKFLOWS_FILENAME = "workflows.local.json";
19
+
20
+ type LocalWorkflowRecord = {
21
+ workflow_id: string;
22
+ version: number;
23
+ updated_at: string;
24
+ definition: unknown;
25
+ };
26
+
27
+ type LocalWorkflowsState = {
28
+ workflows?: Record<string, LocalWorkflowRecord>;
29
+ };
30
+
31
+ export function syncedWorkflowsCachePath(dir?: string): string {
32
+ return join(dir ?? join(homedir(), ".cortex"), SYNCED_WORKFLOWS_FILENAME);
33
+ }
34
+
35
+ /**
36
+ * Returns the synced org-authored workflows keyed by `workflow_id`.
37
+ * Empty object when the cache is missing, unreadable, malformed, or
38
+ * contains no valid entries. The optional `dir` argument is for tests;
39
+ * production callers leave it unset.
40
+ */
41
+ export function loadSyncedWorkflows(
42
+ dir?: string,
43
+ ): Record<string, WorkflowDefinition> {
44
+ const path = syncedWorkflowsCachePath(dir);
45
+ if (!existsSync(path)) return {};
46
+
47
+ let parsed: LocalWorkflowsState;
48
+ try {
49
+ parsed = JSON.parse(readFileSync(path, "utf8")) as LocalWorkflowsState;
50
+ } catch {
51
+ return {};
52
+ }
53
+ const records = parsed.workflows;
54
+ if (!records || typeof records !== "object") return {};
55
+
56
+ const out: Record<string, WorkflowDefinition> = {};
57
+ for (const [id, record] of Object.entries(records)) {
58
+ if (!record || typeof record !== "object") continue;
59
+ const result = workflowDefinitionSchema.safeParse(record.definition);
60
+ if (!result.success) continue;
61
+ out[id] = result.data;
62
+ }
63
+ return out;
64
+ }
@@ -28,6 +28,7 @@ import {
28
28
  } from "./heartbeat-tracker.js";
29
29
  import { startSyncTimer } from "./sync-checker.js";
30
30
  import { startSkillSyncTimer } from "./skill-sync-checker.js";
31
+ import { startWorkflowSyncTimer } from "./workflow-sync-checker.js";
31
32
  import { startHostEventsPusher } from "./host-events-pusher.js";
32
33
  import { startEgressProxy } from "./egress-proxy.js";
33
34
  import { startHeartbeatPusher } from "./heartbeat-pusher.js";
@@ -357,6 +358,20 @@ async function main(): Promise<void> {
357
358
  startSkillSyncTimer(process.cwd(), skillSyncMs);
358
359
  }
359
360
 
361
+ // Harness Phase 2: poll cortex-web for org-authored workflows, cache
362
+ // their definitions locally so cortex.workflow.start can resolve
363
+ // org-specific workflow_ids ahead of bundled defaults. Same cadence
364
+ // as the skill sync by default; independently configurable via
365
+ // CORTEX_WORKFLOW_SYNC_MS / CORTEX_DISABLE_WORKFLOW_SYNC.
366
+ const workflowSyncRaw = parseInt(process.env.CORTEX_WORKFLOW_SYNC_MS ?? "", 10);
367
+ const workflowSyncMs =
368
+ Number.isFinite(workflowSyncRaw) && workflowSyncRaw > 0
369
+ ? workflowSyncRaw
370
+ : skillSyncMs;
371
+ if (process.env.CORTEX_DISABLE_WORKFLOW_SYNC !== "1") {
372
+ startWorkflowSyncTimer(process.cwd(), workflowSyncMs);
373
+ }
374
+
360
375
  // Govern host heartbeat — fills host_enrollment on cortex-web so the
361
376
  // dashboard at /dashboard/govern actually shows this host.
362
377
  const heartbeatRaw = parseInt(process.env.CORTEX_HEARTBEAT_PUSH_MS ?? "", 10);