@treeseed/core 0.6.37 → 0.6.39

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,163 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath } from "node:url";
3
+ import { createServiceSdk } from "./common.js";
4
+ import {
5
+ resolveManagerServiceConfig,
6
+ runManagerAction,
7
+ runManagerCycle
8
+ } from "./manager.js";
9
+ function readDate(value) {
10
+ if (typeof value !== "string") return null;
11
+ const parsed = new Date(value);
12
+ return Number.isFinite(parsed.valueOf()) ? parsed : null;
13
+ }
14
+ function workdayCloseDeadline(workDay, durationMinutes, now) {
15
+ const startedAt = readDate(workDay?.startedAt) ?? readDate(workDay?.started_at) ?? now;
16
+ return new Date(startedAt.valueOf() + durationMinutes * 60 * 1e3);
17
+ }
18
+ async function sleep(ms) {
19
+ await new Promise((resolve) => setTimeout(resolve, ms));
20
+ }
21
+ async function acquireLease(sdk, config, workDayId) {
22
+ if (typeof sdk.claimWorkdayManagerLease !== "function") {
23
+ return { payload: { id: `local:${config.managerId}`, managerId: config.managerId } };
24
+ }
25
+ return sdk.claimWorkdayManagerLease({
26
+ projectId: config.projectId,
27
+ environment: config.environment,
28
+ workDayId,
29
+ managerId: config.managerId,
30
+ ttlSeconds: Math.max(60, Math.ceil(config.pollIntervalMs / 1e3) * 4),
31
+ staleAfterSeconds: Math.max(120, Math.ceil(config.pollIntervalMs / 1e3) * 8),
32
+ metadata: {
33
+ service: "workdayManager"
34
+ }
35
+ });
36
+ }
37
+ async function heartbeatLease(sdk, config, leaseId, workDayId) {
38
+ if (typeof sdk.claimWorkdayManagerLease !== "function") return;
39
+ await sdk.claimWorkdayManagerLease({
40
+ id: leaseId,
41
+ projectId: config.projectId,
42
+ environment: config.environment,
43
+ workDayId,
44
+ managerId: config.managerId,
45
+ ttlSeconds: Math.max(60, Math.ceil(config.pollIntervalMs / 1e3) * 4),
46
+ metadata: {
47
+ service: "workdayManager",
48
+ heartbeat: true
49
+ }
50
+ });
51
+ }
52
+ async function releaseLease(sdk, managerId, leaseId) {
53
+ if (!leaseId || typeof sdk.releaseWorkdayManagerLease !== "function") return;
54
+ await sdk.releaseWorkdayManagerLease({ id: leaseId, managerId }).catch(() => null);
55
+ }
56
+ async function recordRunnerCloseDecision(sdk, config, workDayId, action, reason) {
57
+ if (typeof sdk.recordRunnerScaleDecision !== "function") return;
58
+ const runners = typeof sdk.listWorkerRunners === "function" ? (await sdk.listWorkerRunners(config.projectId, config.environment).catch(() => ({ payload: [] }))).payload ?? [] : [];
59
+ if (runners.length === 0) {
60
+ await sdk.recordRunnerScaleDecision({
61
+ projectId: config.projectId,
62
+ environment: config.environment,
63
+ workDayId,
64
+ action: "noop",
65
+ reason: `${reason}:no_runners`,
66
+ metadata: { service: "workdayManager" }
67
+ }).catch(() => null);
68
+ return;
69
+ }
70
+ for (const runner of runners) {
71
+ await sdk.recordRunnerScaleDecision({
72
+ projectId: config.projectId,
73
+ environment: config.environment,
74
+ workDayId,
75
+ runnerId: typeof runner.runnerId === "string" ? runner.runnerId : null,
76
+ runnerServiceName: typeof runner.runnerServiceName === "string" ? runner.runnerServiceName : null,
77
+ action,
78
+ reason,
79
+ metadata: { service: "workdayManager" }
80
+ }).catch(() => null);
81
+ }
82
+ }
83
+ async function runScheduledWorkdayManager(options = {}) {
84
+ const sdk = options.sdk ?? createServiceSdk();
85
+ const config = options.config ?? {
86
+ ...resolveManagerServiceConfig(),
87
+ mode: "reconcile"
88
+ };
89
+ let now = options.now ?? /* @__PURE__ */ new Date();
90
+ const policyEnvelope = await sdk.getWorkPolicy(config.projectId, config.environment);
91
+ const policy = policyEnvelope.payload ?? await sdk.upsertWorkPolicy({
92
+ projectId: config.projectId,
93
+ environment: config.environment,
94
+ schedule: config.defaultSchedule,
95
+ enabled: true,
96
+ startCron: process.env.TREESEED_WORKDAY_START_CRON?.trim() || "0 9 * * 1-5",
97
+ durationMinutes: Number(process.env.TREESEED_WORKDAY_DURATION_MINUTES ?? 480),
98
+ maxRunners: config.autoscale.maxWorkers,
99
+ maxWorkersPerRunner: Number(process.env.TREESEED_RUNNER_MAX_LOCAL_WORKERS ?? 4),
100
+ dailyCreditBudget: config.dailyTaskCreditBudget,
101
+ closeoutGraceMinutes: Number(process.env.TREESEED_WORKDAY_CLOSEOUT_GRACE_MINUTES ?? 15),
102
+ dailyTaskCreditBudget: config.dailyTaskCreditBudget,
103
+ maxQueuedTasks: config.maxQueuedTasks,
104
+ maxQueuedCredits: config.maxQueuedCredits,
105
+ autoscale: config.autoscale,
106
+ creditWeights: config.creditWeights,
107
+ metadata: { managedBy: "workdayManager" }
108
+ }).then((created) => created.payload);
109
+ if (!policy?.enabled) {
110
+ return { ok: true, skipped: true, reason: "workday_policy_disabled" };
111
+ }
112
+ const requests = typeof sdk.listWorkdayRequests === "function" ? (await sdk.listWorkdayRequests(config.projectId, config.environment, "pending").catch(() => ({ payload: [] }))).payload ?? [] : [];
113
+ const oneOffRunRequested = requests.some((entry) => entry.type === "one_off_run");
114
+ const initial = await runManagerCycle({ sdk, config, now });
115
+ const workDay = initial.workDay;
116
+ if (!workDay && !oneOffRunRequested) {
117
+ return { ok: true, skipped: true, reason: "no_workday_started", initial };
118
+ }
119
+ const lease = await acquireLease(sdk, config, workDay ? String(workDay.id ?? "") : null);
120
+ if (!lease.payload) {
121
+ return { ok: true, skipped: true, reason: "healthy_manager_lease_exists" };
122
+ }
123
+ const leaseId = String(lease.payload.id ?? "");
124
+ let latest = initial;
125
+ try {
126
+ let activeWorkDay = workDay;
127
+ let closeDeadline = workdayCloseDeadline(activeWorkDay, Number(policy.durationMinutes ?? 480), now);
128
+ while (Date.now() < closeDeadline.valueOf()) {
129
+ await heartbeatLease(sdk, config, leaseId, activeWorkDay ? String(activeWorkDay.id ?? "") : null);
130
+ await sleep(config.pollIntervalMs);
131
+ now = /* @__PURE__ */ new Date();
132
+ latest = await runManagerCycle({ sdk, config, now });
133
+ activeWorkDay = latest.workDay;
134
+ closeDeadline = workdayCloseDeadline(activeWorkDay, Number(policy.durationMinutes ?? 480), now);
135
+ }
136
+ const workDayId = activeWorkDay ? String(activeWorkDay.id ?? "") : null;
137
+ await recordRunnerCloseDecision(sdk, config, workDayId, "drain", "workday_closeout");
138
+ const graceMs = Number(policy.closeoutGraceMinutes ?? 15) * 60 * 1e3;
139
+ const graceEnd = Date.now() + graceMs;
140
+ while (Date.now() < graceEnd) {
141
+ const result = await runManagerCycle({ sdk, config, now: /* @__PURE__ */ new Date() });
142
+ latest = result;
143
+ if (Number(result.queuedCount ?? 0) === 0 && Number(result.activeLeases ?? 0) === 0) {
144
+ break;
145
+ }
146
+ await sleep(config.pollIntervalMs);
147
+ }
148
+ const closed = await runManagerAction({ sdk, config, mode: "close-workday" });
149
+ await recordRunnerCloseDecision(sdk, config, workDayId, "sleep", "workday_closed");
150
+ return { ok: true, initial, latest, closed };
151
+ } finally {
152
+ await releaseLease(sdk, config.managerId, leaseId);
153
+ }
154
+ }
155
+ const currentFile = fileURLToPath(import.meta.url);
156
+ const entryFile = process.argv[1] ?? "";
157
+ if (entryFile === currentFile) {
158
+ process.stdout.write(`${JSON.stringify(await runScheduledWorkdayManager(), null, 2)}
159
+ `);
160
+ }
161
+ export {
162
+ runScheduledWorkdayManager
163
+ };
@@ -4,7 +4,7 @@ export declare function runWorkdayReport(): Promise<{
4
4
  mode: "reconcile";
5
5
  managerId: string;
6
6
  projectId: string;
7
- environment: "local" | "staging" | "prod";
7
+ environment: "local" | "prod" | "staging";
8
8
  insideWorkWindow: boolean;
9
9
  workPolicy: import("@treeseed/sdk").WorkdayPolicy;
10
10
  workDay: Record<string, unknown>;
@@ -55,8 +55,18 @@ export declare function runWorkdayReport(): Promise<{
55
55
  reportVersion: string;
56
56
  title: string;
57
57
  };
58
+ capacity: {
59
+ providerSplit: any;
60
+ grantedDailyCredits: number;
61
+ reservedCredits: number;
62
+ consumedCredits: number;
63
+ remainingDailyCredits: number | null;
64
+ providerCount: number;
65
+ laneCount: number;
66
+ grantCount: number;
67
+ };
58
68
  projectId: string;
59
- environment: "local" | "staging" | "prod";
69
+ environment: "local" | "prod" | "staging";
60
70
  workDayId: string;
61
71
  state: string;
62
72
  totalTasks: number;
@@ -122,8 +132,18 @@ export declare function runWorkdayReport(): Promise<{
122
132
  reportVersion: string;
123
133
  title: string;
124
134
  };
135
+ capacity: {
136
+ providerSplit: any;
137
+ grantedDailyCredits: number;
138
+ reservedCredits: number;
139
+ consumedCredits: number;
140
+ remainingDailyCredits: number | null;
141
+ providerCount: number;
142
+ laneCount: number;
143
+ grantCount: number;
144
+ };
125
145
  projectId: string;
126
- environment: "local" | "staging" | "prod";
146
+ environment: "local" | "prod" | "staging";
127
147
  workDayId: string;
128
148
  state: string;
129
149
  totalTasks: number;
@@ -4,7 +4,7 @@ export declare function runWorkdayStart(): Promise<{
4
4
  mode: "reconcile";
5
5
  managerId: string;
6
6
  projectId: string;
7
- environment: "local" | "staging" | "prod";
7
+ environment: "local" | "prod" | "staging";
8
8
  insideWorkWindow: boolean;
9
9
  workPolicy: import("@treeseed/sdk").WorkdayPolicy;
10
10
  workDay: Record<string, unknown>;
@@ -55,8 +55,18 @@ export declare function runWorkdayStart(): Promise<{
55
55
  reportVersion: string;
56
56
  title: string;
57
57
  };
58
+ capacity: {
59
+ providerSplit: any;
60
+ grantedDailyCredits: number;
61
+ reservedCredits: number;
62
+ consumedCredits: number;
63
+ remainingDailyCredits: number | null;
64
+ providerCount: number;
65
+ laneCount: number;
66
+ grantCount: number;
67
+ };
58
68
  projectId: string;
59
- environment: "local" | "staging" | "prod";
69
+ environment: "local" | "prod" | "staging";
60
70
  workDayId: string;
61
71
  state: string;
62
72
  totalTasks: number;
@@ -122,8 +132,18 @@ export declare function runWorkdayStart(): Promise<{
122
132
  reportVersion: string;
123
133
  title: string;
124
134
  };
135
+ capacity: {
136
+ providerSplit: any;
137
+ grantedDailyCredits: number;
138
+ reservedCredits: number;
139
+ consumedCredits: number;
140
+ remainingDailyCredits: number | null;
141
+ providerCount: number;
142
+ laneCount: number;
143
+ grantCount: number;
144
+ };
125
145
  projectId: string;
126
- environment: "local" | "staging" | "prod";
146
+ environment: "local" | "prod" | "staging";
127
147
  workDayId: string;
128
148
  state: string;
129
149
  totalTasks: number;
@@ -7,7 +7,6 @@ export interface RailwayWorkerPoolScalerOptions {
7
7
  environmentId?: string | null;
8
8
  projectId?: string | null;
9
9
  fetchImpl?: typeof fetch;
10
- mutation?: string | null;
11
10
  }
12
11
  export declare class NoopWorkerPoolScaler implements WorkerPoolScaler {
13
12
  scale(decision: ScaleDecision): Promise<WorkerPoolScaleResult>;
@@ -19,9 +18,10 @@ export declare class RailwayWorkerPoolScaler implements WorkerPoolScaler {
19
18
  private readonly environmentId;
20
19
  private readonly projectId;
21
20
  private readonly fetchImpl;
22
- private readonly mutation;
23
21
  constructor(options?: RailwayWorkerPoolScalerOptions);
24
- private configured;
22
+ private railwayMutation;
23
+ private wakeRunner;
24
+ private sleepRunner;
25
25
  scale(decision: ScaleDecision): Promise<WorkerPoolScaleResult>;
26
26
  }
27
27
  export declare function createWorkerPoolScaler(kind?: WorkerPoolScalerKind | null, options?: RailwayWorkerPoolScalerOptions): WorkerPoolScaler;
@@ -2,18 +2,6 @@ function envValue(name) {
2
2
  const value = process.env[name]?.trim();
3
3
  return value ? value : "";
4
4
  }
5
- const DEFAULT_RAILWAY_API_URL = "https://backboard.railway.com/graphql/v2";
6
- const DEFAULT_SCALE_MUTATION = `
7
- mutation TreeseedScaleService($serviceId: String!, $environmentId: String!, $replicas: Int!) {
8
- serviceInstanceUpdate(
9
- serviceId: $serviceId
10
- environmentId: $environmentId
11
- input: { numReplicas: $replicas }
12
- ) {
13
- id
14
- }
15
- }
16
- `.trim();
17
5
  class NoopWorkerPoolScaler {
18
6
  async scale(decision) {
19
7
  return {
@@ -33,69 +21,99 @@ class RailwayWorkerPoolScaler {
33
21
  environmentId;
34
22
  projectId;
35
23
  fetchImpl;
36
- mutation;
37
24
  constructor(options = {}) {
38
25
  this.apiToken = options.apiToken?.trim() || envValue("RAILWAY_API_TOKEN") || null;
39
- this.apiUrl = options.apiUrl?.trim() || envValue("TREESEED_RAILWAY_API_URL") || DEFAULT_RAILWAY_API_URL;
26
+ this.apiUrl = options.apiUrl?.trim() || envValue("TREESEED_RAILWAY_API_URL") || "https://backboard.railway.com/graphql/v2";
40
27
  this.serviceId = options.serviceId?.trim() || envValue("TREESEED_RAILWAY_WORKER_SERVICE_ID") || envValue("TREESEED_WORKER_SERVICE_ID") || null;
41
28
  this.environmentId = options.environmentId?.trim() || envValue("TREESEED_RAILWAY_ENVIRONMENT_ID") || null;
42
29
  this.projectId = options.projectId?.trim() || envValue("TREESEED_RAILWAY_PROJECT_ID") || null;
43
30
  this.fetchImpl = options.fetchImpl ?? fetch;
44
- this.mutation = options.mutation?.trim() || envValue("TREESEED_RAILWAY_SCALE_MUTATION") || DEFAULT_SCALE_MUTATION;
45
31
  }
46
- configured() {
47
- return Boolean(this.apiToken && this.serviceId && this.environmentId);
32
+ async railwayMutation(query, variables) {
33
+ if (!this.apiToken) {
34
+ throw new Error("Configure RAILWAY_API_TOKEN before waking Railway worker runners.");
35
+ }
36
+ const response = await this.fetchImpl(this.apiUrl, {
37
+ method: "POST",
38
+ headers: {
39
+ authorization: `Bearer ${this.apiToken}`,
40
+ "content-type": "application/json"
41
+ },
42
+ body: JSON.stringify({ query, variables })
43
+ });
44
+ const payload = await response.json().catch(() => ({}));
45
+ if (!response.ok || Array.isArray(payload.errors) && payload.errors.length > 0) {
46
+ throw new Error(payload.errors?.[0]?.message ?? `Railway runner request failed with ${response.status}.`);
47
+ }
48
+ return payload.data;
49
+ }
50
+ async wakeRunner() {
51
+ if (!this.serviceId || !this.environmentId) {
52
+ throw new Error("Railway runner wake requires serviceId and environmentId.");
53
+ }
54
+ const mutation = envValue("TREESEED_RAILWAY_RUNNER_WAKE_MUTATION") || `
55
+ mutation TreeseedRailwayRunnerWake($serviceId: String!, $environmentId: String!) {
56
+ serviceInstanceRedeploy(serviceId: $serviceId, environmentId: $environmentId)
57
+ }
58
+ `.trim();
59
+ return await this.railwayMutation(mutation, {
60
+ serviceId: this.serviceId,
61
+ environmentId: this.environmentId,
62
+ projectId: this.projectId
63
+ });
64
+ }
65
+ async sleepRunner() {
66
+ if (!this.serviceId || !this.environmentId) {
67
+ throw new Error("Railway runner sleep requires serviceId and environmentId.");
68
+ }
69
+ const mutation = envValue("TREESEED_RAILWAY_RUNNER_SLEEP_MUTATION") || `
70
+ mutation TreeseedRailwayRunnerSleep($serviceId: String!, $environmentId: String!) {
71
+ deploymentRemove(serviceId: $serviceId, environmentId: $environmentId)
72
+ }
73
+ `.trim();
74
+ return await this.railwayMutation(mutation, {
75
+ serviceId: this.serviceId,
76
+ environmentId: this.environmentId,
77
+ projectId: this.projectId
78
+ });
48
79
  }
49
80
  async scale(decision) {
50
- if (!this.configured() || !this.apiToken || !this.serviceId || !this.environmentId) {
81
+ const desiredWorkers = Math.max(0, Number(decision.desiredWorkers ?? 0));
82
+ const action = desiredWorkers > 0 ? "wake" : "sleep";
83
+ try {
84
+ const result = action === "wake" ? await this.wakeRunner() : await this.sleepRunner();
51
85
  return {
52
- applied: false,
86
+ applied: true,
53
87
  provider: "railway",
54
- desiredWorkers: decision.desiredWorkers,
88
+ desiredWorkers,
55
89
  metadata: {
56
- reason: "railway_scaler_unconfigured",
90
+ action,
57
91
  projectId: this.projectId,
58
92
  serviceId: this.serviceId,
59
- environmentId: this.environmentId
93
+ environmentId: this.environmentId,
94
+ result
60
95
  }
61
96
  };
62
- }
63
- const response = await this.fetchImpl(this.apiUrl, {
64
- method: "POST",
65
- headers: {
66
- authorization: `Bearer ${this.apiToken}`,
67
- "content-type": "application/json"
68
- },
69
- body: JSON.stringify({
70
- query: this.mutation,
71
- variables: {
97
+ } catch (error) {
98
+ return {
99
+ applied: false,
100
+ provider: "railway",
101
+ desiredWorkers,
102
+ metadata: {
103
+ action,
104
+ reason: "named_runner_action_failed",
105
+ projectId: this.projectId,
72
106
  serviceId: this.serviceId,
73
107
  environmentId: this.environmentId,
74
- replicas: decision.desiredWorkers
108
+ error: error instanceof Error ? error.message : String(error)
75
109
  }
76
- })
77
- });
78
- const payload = await response.json().catch(() => ({}));
79
- if (!response.ok || Array.isArray(payload.errors) && payload.errors.length > 0) {
80
- throw new Error(
81
- payload.errors?.[0]?.message ?? `Railway worker scale request failed with ${response.status}.`
82
- );
110
+ };
83
111
  }
84
- return {
85
- applied: true,
86
- provider: "railway",
87
- desiredWorkers: decision.desiredWorkers,
88
- metadata: {
89
- projectId: this.projectId,
90
- serviceId: this.serviceId,
91
- environmentId: this.environmentId
92
- }
93
- };
94
112
  }
95
113
  }
96
114
  function createWorkerPoolScaler(kind, options = {}) {
97
115
  const configuredKind = envValue("TREESEED_WORKER_POOL_SCALER") || null;
98
- const inferredKind = envValue("RAILWAY_API_TOKEN") && (envValue("TREESEED_RAILWAY_WORKER_SERVICE_ID") || envValue("TREESEED_WORKER_SERVICE_ID")) ? "railway" : "noop";
116
+ const inferredKind = envValue("RAILWAY_API_TOKEN") && (envValue("TREESEED_RAILWAY_WORKER_SERVICE_ID") || envValue("TREESEED_WORKER_SERVICE_ID")) && envValue("TREESEED_RAILWAY_ENVIRONMENT_ID") ? "railway" : "noop";
99
117
  const resolvedKind = kind ?? configuredKind ?? inferredKind;
100
118
  if (resolvedKind === "railway") {
101
119
  return new RailwayWorkerPoolScaler(options);
@@ -10,4 +10,10 @@ export declare function runWorkerCycle(): Promise<{
10
10
  idle?: undefined;
11
11
  reason?: undefined;
12
12
  }>;
13
+ export declare function shouldExitWorkerLoopAfterIdle(options: {
14
+ idleExitMs?: number | null;
15
+ idleSince: number | null;
16
+ now: number;
17
+ processed: number;
18
+ }): boolean;
13
19
  export declare function startWorkerLoop(): Promise<void>;