@worca/app 1.3.0 → 1.4.0-rc.1
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/README.md +85 -6
- package/agents/clarify.meta.json +1 -0
- package/agents/memoryDefragmenter.meta.json +2 -1
- package/agents/reviewer.meta.json +60 -0
- package/agents/worca-cc-code-reviewer.md +33 -0
- package/agents/worca-cc-memory-defragmenter.md +5 -3
- package/agents/workspaceScanner.meta.json +1 -0
- package/package.json +14 -10
- package/scripts/git-diff.mjs +25 -0
- package/scripts/gitDiff.meta.json +18 -0
- package/scripts/js-inline.mjs +11 -0
- package/scripts/js.meta.json +22 -0
- package/scripts/py-inline.py +27 -0
- package/scripts/py.meta.json +22 -0
- package/scripts/shell.meta.json +24 -0
- package/skills/worca/SKILL.md +3 -2
- package/src/cli/models.mjs +247 -0
- package/src/cli/render.mjs +72 -4
- package/src/cli/schedule.mjs +494 -0
- package/src/cli/worca-cc.mjs +1001 -22
- package/src/core/agent-registry.mjs +75 -23
- package/src/core/agent-store.mjs +51 -2
- package/src/core/artifacts.mjs +73 -10
- package/src/core/ask/events.mjs +119 -1
- package/src/core/ask/limits.mjs +32 -4
- package/src/core/ask/mcp-stdio.mjs +12 -0
- package/src/core/ask/model-deps.mjs +126 -0
- package/src/core/ask/model-proposal.mjs +370 -0
- package/src/core/ask/models.mjs +12 -0
- package/src/core/ask/policy-deps.mjs +124 -0
- package/src/core/ask/policy-proposal.mjs +363 -0
- package/src/core/ask/prompt.mjs +74 -9
- package/src/core/ask/proposal.mjs +54 -5
- package/src/core/ask/schedule-deps.mjs +83 -0
- package/src/core/ask/schedule-spec.mjs +310 -0
- package/src/core/ask/script-deps.mjs +357 -0
- package/src/core/ask/source-deps.mjs +52 -0
- package/src/core/ask/source-spec.mjs +157 -0
- package/src/core/ask/spawn.mjs +1 -0
- package/src/core/ask/store.mjs +6 -3
- package/src/core/ask/tool-deps.mjs +4 -0
- package/src/core/ask/tools.mjs +657 -37
- package/src/core/ask/turn.mjs +109 -2
- package/src/core/ask-files.mjs +406 -0
- package/src/core/ask-forms.mjs +195 -0
- package/src/core/ask-projection.mjs +72 -0
- package/src/core/bridge/errors.mjs +84 -0
- package/src/core/bridge/provider-ops.mjs +281 -0
- package/src/core/bridge/providers/copilot.mjs +269 -0
- package/src/core/bridge/providers/endpoint.mjs +257 -0
- package/src/core/bridge/registry.mjs +88 -0
- package/src/core/bridge/semaphore.mjs +73 -0
- package/src/core/bridge/server.mjs +184 -0
- package/src/core/bridge/telemetry.mjs +53 -0
- package/src/core/bridge/translate/request.mjs +252 -0
- package/src/core/bridge/translate/response.mjs +82 -0
- package/src/core/bridge/translate/stream.mjs +242 -0
- package/src/core/bridge/upstream.mjs +209 -0
- package/src/core/chat/command-router.mjs +58 -4
- package/src/core/chat/notifier.mjs +14 -1
- package/src/core/chat/renderers.mjs +35 -0
- package/src/core/claude-runner.mjs +126 -19
- package/src/core/config.mjs +212 -31
- package/src/core/cost-budget.mjs +3 -2
- package/src/core/db.mjs +169 -15
- package/src/core/failure-policy.mjs +10 -0
- package/src/core/fs-browse.mjs +16 -4
- package/src/core/git-info.mjs +22 -0
- package/src/core/graph/builtin-workflows.mjs +3 -1
- package/src/core/graph/exec-io.mjs +71 -0
- package/src/core/graph/executor.mjs +139 -70
- package/src/core/graph/human-evidence.mjs +131 -0
- package/src/core/graph/python-probe.mjs +172 -0
- package/src/core/graph/registry-ports.mjs +10 -6
- package/src/core/graph/scheduler.mjs +39 -24
- package/src/core/graph/script-child.mjs +81 -0
- package/src/core/graph/script-runner.mjs +597 -0
- package/src/core/graph/worca_script.py +207 -0
- package/src/core/guardrail-store.mjs +16 -0
- package/src/core/human-backfill.mjs +108 -0
- package/src/core/human-rate.mjs +17 -0
- package/src/core/index-html.mjs +6 -2
- package/src/core/memory-defrag-model.mjs +112 -0
- package/src/core/memory-store.mjs +70 -18
- package/src/core/memory-sync.mjs +22 -11
- package/src/core/metrics/read.mjs +4 -1
- package/src/core/metrics/record.mjs +50 -2
- package/src/core/metrics/sync.mjs +6 -4
- package/src/core/model-env.mjs +149 -0
- package/src/core/model-test.mjs +14 -1
- package/src/core/notifications.mjs +128 -0
- package/src/core/onboarding.mjs +8 -2
- package/src/core/orchestrator.mjs +357 -26
- package/src/core/phases.mjs +95 -6
- package/src/core/plugin-api.mjs +24 -7
- package/src/core/plugin-manifest.mjs +184 -18
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-script-cases.mjs +118 -0
- package/src/core/plugin-store.mjs +163 -17
- package/src/core/plugin-workflows.mjs +71 -17
- package/src/core/policy/cache.mjs +116 -0
- package/src/core/policy/effective.mjs +175 -0
- package/src/core/policy/gate.mjs +91 -0
- package/src/core/policy/local.mjs +145 -0
- package/src/core/policy/registry.mjs +330 -0
- package/src/core/policy/scope.mjs +61 -0
- package/src/core/policy/state.mjs +79 -0
- package/src/core/policy/sync.mjs +513 -0
- package/src/core/protocol.mjs +43 -0
- package/src/core/run-harness.mjs +421 -72
- package/src/core/scheduler.mjs +980 -0
- package/src/core/script-bench.mjs +628 -0
- package/src/core/script-registry.mjs +116 -0
- package/src/core/script-store.mjs +563 -0
- package/src/core/settings.mjs +489 -14
- package/src/core/stats.mjs +33 -2
- package/src/core/workflow-export.mjs +94 -3
- package/src/core/workflow-share.mjs +67 -18
- package/src/core/workflows.mjs +47 -11
- package/src/core/workspaces.mjs +18 -12
- package/src/shared/forms/answer.mjs +164 -0
- package/src/shared/forms/catalog.mjs +91 -0
- package/src/shared/forms/form-def.mjs +290 -0
- package/src/shared/forms/layout.mjs +67 -0
- package/src/shared/forms/paths.mjs +47 -0
- package/src/shared/forms/project.mjs +309 -0
- package/src/shared/forms/schema.mjs +205 -0
- package/src/shared/graph/agent-meta.mjs +55 -5
- package/src/shared/graph/constants.mjs +14 -2
- package/src/shared/graph/flow-layout.mjs +2 -1
- package/src/shared/graph/isomorphic.mjs +5 -3
- package/src/shared/graph/manifest.mjs +22 -13
- package/src/shared/graph/ports.mjs +45 -19
- package/src/shared/graph/script-cases.mjs +257 -0
- package/src/shared/graph/script-icons.mjs +46 -0
- package/src/shared/graph/script-infer.mjs +259 -0
- package/src/shared/graph/script-meta.mjs +408 -0
- package/src/shared/graph/script-templates.mjs +201 -0
- package/src/shared/graph/template.mjs +4 -4
- package/src/shared/graph/validate.mjs +89 -16
- package/src/shared/human-estimate.mjs +100 -0
- package/src/shared/schedule/recurrence.mjs +353 -0
- package/src/shared/team-metrics/aggregate.mjs +51 -11
- package/{scripts → tools}/install.mjs +3 -3
- package/ui/public/app.js +4390 -683
- package/ui/public/artifact-picker.mjs +189 -0
- package/ui/public/ask/dom.mjs +121 -0
- package/ui/public/ask/form-preview.mjs +55 -0
- package/ui/public/ask/form-renderer.mjs +250 -0
- package/ui/public/ask/registry.mjs +53 -0
- package/ui/public/ask/widgets-display.mjs +370 -0
- package/ui/public/ask/widgets-input.mjs +624 -0
- package/ui/public/ask/widgets-layout.mjs +90 -0
- package/ui/public/ask-panel.mjs +401 -27
- package/ui/public/ask-run-card.mjs +1 -1
- package/ui/public/bridge-view.mjs +694 -0
- package/ui/public/chat-settings-view.mjs +24 -0
- package/ui/public/code-editor.mjs +181 -0
- package/ui/public/getting-started.mjs +34 -7
- package/ui/public/graph/composer.mjs +138 -10
- package/ui/public/graph/inspector.mjs +61 -58
- package/ui/public/graph/palette.mjs +27 -9
- package/ui/public/graph/run-decor.mjs +34 -17
- package/ui/public/graph/run-hosts.mjs +7 -1
- package/ui/public/graph/save-dialog.mjs +3 -0
- package/ui/public/graph/view.mjs +23 -7
- package/ui/public/guardrails-view.mjs +15 -3
- package/ui/public/guide-spot.mjs +87 -9
- package/ui/public/index.html +480 -141
- package/ui/public/memory-view.mjs +22 -4
- package/ui/public/models-view.mjs +162 -17
- package/ui/public/node-tunables.mjs +33 -4
- package/ui/public/plugins-view.mjs +23 -1
- package/ui/public/results-view.mjs +4 -2
- package/ui/public/schedule-sheet.mjs +430 -0
- package/ui/public/schedules-view.mjs +432 -0
- package/ui/public/script-bench-view.mjs +1154 -0
- package/ui/public/script-forms.mjs +282 -0
- package/ui/public/script-wizard.mjs +529 -0
- package/ui/public/scripts-view.mjs +868 -0
- package/ui/public/stats-view.mjs +159 -52
- package/ui/public/style.css +1461 -44
- package/ui/public/team-metrics-surfaces.mjs +77 -16
- package/ui/public/team-metrics-view.mjs +68 -5
- package/ui/public/team-policy-view.mjs +1402 -0
- package/ui/public/ui-level.mjs +237 -0
- package/ui/server.mjs +1827 -73
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
// src/core/scheduler.mjs
|
|
2
|
+
// Scheduled runs (schema v31): the launch-ticket store, the recurring-schedule
|
|
3
|
+
// parents, and the due-ticket loop. NO timers live here and NO run is started here:
|
|
4
|
+
// the host (ui/server.mjs' tick, or a `--wait` CLI) passes a `start` function, and
|
|
5
|
+
// every time-dependent function takes `now` — so the whole module is testable with
|
|
6
|
+
// an injected clock.
|
|
7
|
+
//
|
|
8
|
+
// Model
|
|
9
|
+
// scheduled_runs one TICKET per planned start. Its id is the runId UUID the fired
|
|
10
|
+
// run will carry, so links made at scheduling time survive the start.
|
|
11
|
+
// schedules the parent of a recurring series. It materialises only its NEXT
|
|
12
|
+
// occurrence as a ticket; when that ticket reaches an end state the
|
|
13
|
+
// next one is computed. One-shot tickets have no parent.
|
|
14
|
+
//
|
|
15
|
+
// A waiting run is NEVER a row in `pipelines`: the pipeline row is still born inside
|
|
16
|
+
// run(), so stats, sweeps, the delete guard and team metrics never meet it.
|
|
17
|
+
//
|
|
18
|
+
// Ticket states: scheduled -> firing -> fired (then the pipeline's own states)
|
|
19
|
+
// scheduled -> canceled | skipped | missed
|
|
20
|
+
// firing -> failed | scheduled (transient error, retry_at set)
|
|
21
|
+
// missed -> scheduled (Run now / reschedule)
|
|
22
|
+
|
|
23
|
+
import { randomUUID, randomBytes } from 'node:crypto';
|
|
24
|
+
import { hostname } from 'node:os';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { rmSync } from 'node:fs';
|
|
27
|
+
|
|
28
|
+
import { getDb, tx } from './db.mjs';
|
|
29
|
+
import { worcaHome } from './projects.mjs';
|
|
30
|
+
import { projectKey } from './store.mjs';
|
|
31
|
+
import { addNotification, resolveNotifications } from './notifications.mjs';
|
|
32
|
+
import {
|
|
33
|
+
normalizeRule, nextOccurrence, describeRule, OVERLAP_POLICIES, MISSED_POLICIES,
|
|
34
|
+
} from '../shared/schedule/recurrence.mjs';
|
|
35
|
+
|
|
36
|
+
export const TICKET_STATUSES = ['scheduled', 'firing', 'fired', 'canceled', 'skipped', 'missed', 'failed'];
|
|
37
|
+
/** An after-ticket's run_at until its gate opens: an older build's dueTickets never sees it (spec D11). */
|
|
38
|
+
export const AFTER_RUN_AT = '9999-12-31T00:00:00.000Z';
|
|
39
|
+
export const AFTER_POLICIES = ['done', 'any'];
|
|
40
|
+
const AFTER_KINDS = ['ticket', 'pipeline'];
|
|
41
|
+
/** Ticket states that still occupy their schedule's single "next occurrence" slot. */
|
|
42
|
+
const OPEN_TICKET = ['scheduled', 'firing'];
|
|
43
|
+
/** Minutes to wait before attempt n+1 after a transient start error. */
|
|
44
|
+
export const RETRY_BACKOFF_MIN = [1, 2, 5, 10, 15, 30];
|
|
45
|
+
/** A start this close to its slot is "on time" (the tick is 30 s). */
|
|
46
|
+
export const ON_TIME_SLACK_MS = 90_000;
|
|
47
|
+
/** A late start beyond this is worth a feed item. */
|
|
48
|
+
export const LATE_NOTICE_MS = 5 * 60_000;
|
|
49
|
+
/** Pipeline statuses under which a previous occurrence still counts as overlapping. */
|
|
50
|
+
const LIVE_PIPELINE = ['created', 'starting', 'running', 'pausing'];
|
|
51
|
+
/** A fired ticket whose pipeline id is not known yet counts as live this long. */
|
|
52
|
+
const FIRED_UNKNOWN_LIVE_MS = 3 * 60_000;
|
|
53
|
+
export const TICKET_RETENTION_DAYS = 30;
|
|
54
|
+
/** Another host's owner is trusted while it keeps touching its ticket. */
|
|
55
|
+
const FOREIGN_OWNER_FRESH_MS = 2 * 60_000;
|
|
56
|
+
const STALE_FIRING_MS = 10 * 60_000;
|
|
57
|
+
|
|
58
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
59
|
+
const parseJson = (s, fallback = null) => { try { return JSON.parse(s); } catch { return fallback; } };
|
|
60
|
+
|
|
61
|
+
/** True when `pid` is a live process on THIS host (EPERM = alive, not ours). */
|
|
62
|
+
export function defaultPidAlive(pid) {
|
|
63
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
64
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e && e.code === 'EPERM'; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── staging (durable extras) ─────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/** `<worcaHome>/scheduled/<id>` — where a ticket's or a series' uploaded extras wait. */
|
|
70
|
+
export function scheduleStageDir(id) {
|
|
71
|
+
return join(worcaHome(), 'scheduled', String(id).replace(/[^A-Za-z0-9_-]/g, ''));
|
|
72
|
+
}
|
|
73
|
+
function removeStage(id) {
|
|
74
|
+
try { rmSync(scheduleStageDir(id), { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── row mapping ──────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/** The display summary of a stored request — never the full prompt or file bodies. */
|
|
80
|
+
export function summarizeRequest(req) {
|
|
81
|
+
const r = req && typeof req === 'object' ? req : {};
|
|
82
|
+
const text = typeof r.prompt === 'string' && r.prompt ? r.prompt : (typeof r.promptMarkdown === 'string' ? r.promptMarkdown : '');
|
|
83
|
+
const src = r.source && typeof r.source === 'object' ? r.source : null;
|
|
84
|
+
return {
|
|
85
|
+
target: r.workspaceId ? 'workspace' : 'project',
|
|
86
|
+
workflowId: r.workflowId || 'wf_default',
|
|
87
|
+
guardrailsId: r.guardrailsId || null,
|
|
88
|
+
prompt: text.length > 280 ? `${text.slice(0, 280)}…` : text,
|
|
89
|
+
source: src ? { type: src.type || null, plugin: src.plugin || null, sourceId: src.sourceId || null, taskId: src.taskId || null, title: src.title || null, url: src.url || null } : null,
|
|
90
|
+
sourceBranch: r.sourceBranch || null,
|
|
91
|
+
featureBranch: r.featureBranch || null,
|
|
92
|
+
memoryScope: r.memoryScope || null,
|
|
93
|
+
mock: !!r.mock,
|
|
94
|
+
extras: Array.isArray(r.internal?.extrasPaths) ? r.internal.extrasPaths.length : 0,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function rowToTicket(r, { withRequest = false } = {}) {
|
|
99
|
+
if (!r) return null;
|
|
100
|
+
const request = parseJson(r.request, {});
|
|
101
|
+
const t = {
|
|
102
|
+
id: r.id,
|
|
103
|
+
kind: 'once',
|
|
104
|
+
scheduleId: r.schedule_id || null,
|
|
105
|
+
title: r.title || null,
|
|
106
|
+
projectKey: r.project_key || null,
|
|
107
|
+
projectDir: r.project_dir || null,
|
|
108
|
+
workspaceId: r.workspace_id || null,
|
|
109
|
+
runAt: r.run_at,
|
|
110
|
+
scheduledFor: r.run_at,
|
|
111
|
+
status: r.status,
|
|
112
|
+
ifMissed: r.if_missed,
|
|
113
|
+
graceMin: r.grace_min,
|
|
114
|
+
attempts: r.attempts,
|
|
115
|
+
retryAt: r.retry_at || null,
|
|
116
|
+
queued: !!r.queued,
|
|
117
|
+
forced: !!r.forced,
|
|
118
|
+
after: r.after_kind ? { kind: r.after_kind, id: r.after_id, policy: r.after_policy || 'done' } : null,
|
|
119
|
+
sourceFromPrevious: !!r.source_from_previous,
|
|
120
|
+
ownerPid: r.owner_pid ?? null,
|
|
121
|
+
ownerHost: r.owner_host || null,
|
|
122
|
+
pipelineId: r.pipeline_id || null,
|
|
123
|
+
failReason: r.fail_reason || null,
|
|
124
|
+
askThreadId: r.ask_thread_id || null,
|
|
125
|
+
askCardId: r.ask_card_id || null,
|
|
126
|
+
createdAt: r.created_at,
|
|
127
|
+
updatedAt: r.updated_at,
|
|
128
|
+
summary: summarizeRequest(request),
|
|
129
|
+
};
|
|
130
|
+
if (withRequest) t.request = request;
|
|
131
|
+
return t;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function rowToSchedule(r, { withRequest = false } = {}) {
|
|
135
|
+
if (!r) return null;
|
|
136
|
+
const request = parseJson(r.request, {});
|
|
137
|
+
const rule = parseJson(r.rule, null);
|
|
138
|
+
const s = {
|
|
139
|
+
id: r.id,
|
|
140
|
+
kind: 'recurring',
|
|
141
|
+
title: r.title || null,
|
|
142
|
+
projectKey: r.project_key || null,
|
|
143
|
+
projectDir: r.project_dir || null,
|
|
144
|
+
workspaceId: r.workspace_id || null,
|
|
145
|
+
rule,
|
|
146
|
+
sentence: describeRule(rule),
|
|
147
|
+
tz: rule?.tz || null,
|
|
148
|
+
overlap: r.overlap,
|
|
149
|
+
maxFailures: r.max_failures,
|
|
150
|
+
failureStreak: r.failure_streak,
|
|
151
|
+
ifMissed: r.if_missed,
|
|
152
|
+
graceMin: r.grace_min,
|
|
153
|
+
status: r.status,
|
|
154
|
+
pauseReason: r.pause_reason || null,
|
|
155
|
+
runsCount: r.runs_count,
|
|
156
|
+
nextRunAt: r.next_run_at || null,
|
|
157
|
+
lastResult: r.last_result || null,
|
|
158
|
+
askThreadId: r.ask_thread_id || null,
|
|
159
|
+
askCardId: r.ask_card_id || null,
|
|
160
|
+
createdAt: r.created_at,
|
|
161
|
+
updatedAt: r.updated_at,
|
|
162
|
+
summary: summarizeRequest(request),
|
|
163
|
+
};
|
|
164
|
+
if (withRequest) s.request = request;
|
|
165
|
+
return s;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function targetCols({ projectDir = null, workspaceId = null }) {
|
|
169
|
+
return {
|
|
170
|
+
project_key: projectDir ? projectKey(projectDir) : null,
|
|
171
|
+
project_dir: projectDir || null,
|
|
172
|
+
workspace_id: workspaceId || null,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normPolicy(value, allowed, fallback) {
|
|
177
|
+
return allowed.includes(value) ? value : fallback;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** `{ kind:'ticket'|'pipeline', id }` or null. */
|
|
181
|
+
function normAfter(after) {
|
|
182
|
+
if (!after || typeof after !== 'object') return null;
|
|
183
|
+
const kind = String(after.kind || '');
|
|
184
|
+
const id = typeof after.id === 'string' ? after.id.trim() : '';
|
|
185
|
+
return AFTER_KINDS.includes(kind) && id ? { kind, id } : null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function normGrace(v, fallback = 360) {
|
|
189
|
+
const n = Number(v);
|
|
190
|
+
return Number.isSafeInteger(n) && n >= 0 && n <= 10080 ? n : fallback;
|
|
191
|
+
}
|
|
192
|
+
function normMaxFailures(v, fallback = 3) {
|
|
193
|
+
const n = Number(v);
|
|
194
|
+
return Number.isSafeInteger(n) && n >= 0 && n <= 100 ? n : fallback;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── tickets ──────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Create one launch ticket.
|
|
201
|
+
* @param {object} o
|
|
202
|
+
* @param {string} [o.id] the runId UUID (minted when absent)
|
|
203
|
+
* @param {number} o.runAtMs UTC instant
|
|
204
|
+
* @param {{kind:'ticket'|'pipeline', id:string}|null} [o.after] a predecessor instead of a time: run_at becomes AFTER_RUN_AT and runAtMs is ignored (run chains)
|
|
205
|
+
* @param {'done'|'any'} [o.afterPolicy] with `after`: start only when it finishes `done` (default), or `any` way it ends
|
|
206
|
+
* @param {boolean} [o.sourceFromPrevious] with `after`: start on the predecessor's feature branch, resolved when the ticket fires
|
|
207
|
+
* @param {object} o.request POST /api/run body shape (+ `internal`)
|
|
208
|
+
* @returns {object} the ticket
|
|
209
|
+
*/
|
|
210
|
+
export function createTicket({
|
|
211
|
+
id = randomUUID(), scheduleId = null, title = null, projectDir = null, workspaceId = null,
|
|
212
|
+
runAtMs, request, ifMissed = 'run', graceMin = 360, ownerPid = null, ownerHost = null,
|
|
213
|
+
askThreadId = null, askCardId = null, forced = false,
|
|
214
|
+
after = null, afterPolicy = 'done', sourceFromPrevious = false,
|
|
215
|
+
now = Date.now(),
|
|
216
|
+
}) {
|
|
217
|
+
const chained = normAfter(after);
|
|
218
|
+
if (after && !chained) throw new Error('createTicket: after.kind must be ticket | pipeline and after.id a string');
|
|
219
|
+
if (!chained && !Number.isFinite(runAtMs)) throw new Error('createTicket: runAtMs is required');
|
|
220
|
+
const tc = targetCols({ projectDir, workspaceId });
|
|
221
|
+
const ts = iso(now);
|
|
222
|
+
getDb().prepare(`
|
|
223
|
+
INSERT INTO scheduled_runs (id, schedule_id, title, project_key, project_dir, workspace_id, run_at, request,
|
|
224
|
+
status, if_missed, grace_min, owner_pid, owner_host, ask_thread_id, ask_card_id, forced,
|
|
225
|
+
after_kind, after_id, after_policy, source_from_previous, created_at, updated_at)
|
|
226
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'scheduled', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
227
|
+
`).run(id, scheduleId, title, tc.project_key, tc.project_dir, tc.workspace_id, chained ? AFTER_RUN_AT : iso(runAtMs), JSON.stringify(request || {}),
|
|
228
|
+
normPolicy(ifMissed, MISSED_POLICIES, 'run'), normGrace(graceMin), ownerPid, ownerPid != null ? (ownerHost || hostname()) : null,
|
|
229
|
+
askThreadId, askCardId, forced ? 1 : 0,
|
|
230
|
+
chained ? chained.kind : null, chained ? chained.id : null, normPolicy(afterPolicy, AFTER_POLICIES, 'done'), chained && sourceFromPrevious ? 1 : 0, ts, ts);
|
|
231
|
+
return getTicket(id);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function getTicket(id, opts = {}) {
|
|
235
|
+
return rowToTicket(getDb().prepare('SELECT * FROM scheduled_runs WHERE id = ?').get(String(id)), opts);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* List tickets, soonest first. `open` (default) keeps scheduled/firing/missed — the
|
|
240
|
+
* ones a person can still act on; `all` adds the ended ones.
|
|
241
|
+
*/
|
|
242
|
+
export function listTickets({ projectDir = null, workspaceId = null, scheduleId = null, all = false, oneShotOnly = false, limit = 500 } = {}) {
|
|
243
|
+
const where = [];
|
|
244
|
+
const args = [];
|
|
245
|
+
if (projectDir) { where.push('project_key = ?'); args.push(projectKey(projectDir)); }
|
|
246
|
+
if (workspaceId) { where.push('workspace_id = ?'); args.push(workspaceId); }
|
|
247
|
+
if (scheduleId) { where.push('schedule_id = ?'); args.push(scheduleId); }
|
|
248
|
+
if (oneShotOnly) where.push('schedule_id IS NULL');
|
|
249
|
+
if (!all) where.push("status IN ('scheduled', 'firing', 'missed')");
|
|
250
|
+
const sql = `SELECT * FROM scheduled_runs ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY run_at ASC LIMIT ${Math.max(1, Math.min(2000, Number(limit) || 500))}`;
|
|
251
|
+
return getDb().prepare(sql).all(...args).map((r) => rowToTicket(r));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function touchTicket(id, sets, args, now) {
|
|
255
|
+
return getDb().prepare(`UPDATE scheduled_runs SET ${sets}, updated_at = ? WHERE id = ?`).run(...args, iso(now), id).changes;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Move a waiting (or missed) ticket to a new time and/or change its missed policy.
|
|
260
|
+
* A missed ticket becomes `scheduled` again and its alarm resolves.
|
|
261
|
+
* @returns {object|null} the ticket, or null when it is not editable
|
|
262
|
+
*/
|
|
263
|
+
export function updateTicket(id, { runAtMs, ifMissed, graceMin, after, afterPolicy, sourceFromPrevious } = {}, { now = Date.now() } = {}) {
|
|
264
|
+
const t = getTicket(id);
|
|
265
|
+
if (!t || !['scheduled', 'missed'].includes(t.status)) return null;
|
|
266
|
+
const sets = [];
|
|
267
|
+
const args = [];
|
|
268
|
+
if (Number.isFinite(runAtMs) && after) throw new Error('updateTicket: give runAtMs OR after, not both');
|
|
269
|
+
if (Number.isFinite(runAtMs)) {
|
|
270
|
+
// Back to a time: every after_* column goes, the policy included — a later re-chaining that
|
|
271
|
+
// says nothing about the policy must start from `done`, never from a stale `any`.
|
|
272
|
+
sets.push('run_at = ?', "status = 'scheduled'", 'attempts = 0', 'retry_at = NULL', 'queued = 0', 'forced = 0', 'fail_reason = NULL',
|
|
273
|
+
'after_kind = NULL', 'after_id = NULL', "after_policy = 'done'", 'source_from_previous = 0');
|
|
274
|
+
args.push(iso(runAtMs));
|
|
275
|
+
}
|
|
276
|
+
const chained = after === undefined ? undefined : normAfter(after);
|
|
277
|
+
// `after: null` is "not given"; anything else that does not normalise is a caller bug — refuse it
|
|
278
|
+
// like createTicket does, never answer "no change".
|
|
279
|
+
if (after !== undefined && after !== null && !chained) throw new Error('updateTicket: after.kind must be ticket | pipeline and after.id a string');
|
|
280
|
+
if (chained) {
|
|
281
|
+
sets.push('run_at = ?', 'after_kind = ?', 'after_id = ?', "status = 'scheduled'", 'attempts = 0', 'retry_at = NULL', 'queued = 0', 'forced = 0', 'fail_reason = NULL');
|
|
282
|
+
args.push(AFTER_RUN_AT, chained.kind, chained.id);
|
|
283
|
+
}
|
|
284
|
+
// A policy only rides with (or on) a predecessor. On a move to a time it is dropped, never pushed:
|
|
285
|
+
// a second `after_policy = ?` in the same SET would win over the reset above (SQLite keeps the LAST
|
|
286
|
+
// assignment of a repeated column), and the stale `any` the reset exists to clear would survive.
|
|
287
|
+
if (afterPolicy !== undefined && !Number.isFinite(runAtMs) && AFTER_POLICIES.includes(afterPolicy)) { sets.push('after_policy = ?'); args.push(afterPolicy); }
|
|
288
|
+
if (sourceFromPrevious !== undefined && (chained || (t.after && !Number.isFinite(runAtMs)))) { sets.push('source_from_previous = ?'); args.push(sourceFromPrevious ? 1 : 0); }
|
|
289
|
+
if (ifMissed !== undefined) { sets.push('if_missed = ?'); args.push(normPolicy(ifMissed, MISSED_POLICIES, t.ifMissed)); }
|
|
290
|
+
if (graceMin !== undefined) { sets.push('grace_min = ?'); args.push(normGrace(graceMin, t.graceMin)); }
|
|
291
|
+
if (!sets.length) return t;
|
|
292
|
+
touchTicket(id, sets.join(', '), args, now);
|
|
293
|
+
if (Number.isFinite(runAtMs) || chained) resolveNotifications({ ticketId: id, kinds: ['missed', 'failed'] });
|
|
294
|
+
return getTicket(id);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Cancel a one-shot ticket (scheduled or missed). For an occurrence of a series use
|
|
299
|
+
* skipNext(). @returns {object|null}
|
|
300
|
+
*/
|
|
301
|
+
export function cancelTicket(id, { now = Date.now() } = {}) {
|
|
302
|
+
const changed = getDb().prepare("UPDATE scheduled_runs SET status = 'canceled', updated_at = ? WHERE id = ? AND status IN ('scheduled', 'missed')")
|
|
303
|
+
.run(iso(now), String(id)).changes;
|
|
304
|
+
if (!changed) return null;
|
|
305
|
+
const t = getTicket(id);
|
|
306
|
+
resolveNotifications({ ticketId: id });
|
|
307
|
+
if (!t.scheduleId) removeStage(id);
|
|
308
|
+
return t;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Ask for an immediate start: the ticket becomes due now and bypasses the missed and
|
|
313
|
+
* overlap checks. The host's tick (or the owning `--wait` CLI) picks it up.
|
|
314
|
+
*/
|
|
315
|
+
export function requestRunNow(id, { now = Date.now() } = {}) {
|
|
316
|
+
const changed = getDb().prepare(`UPDATE scheduled_runs SET status = 'scheduled', forced = 1, retry_at = NULL, queued = 0, fail_reason = NULL, updated_at = ?
|
|
317
|
+
WHERE id = ? AND status IN ('scheduled', 'missed')`).run(iso(now), String(id)).changes;
|
|
318
|
+
if (!changed) return null;
|
|
319
|
+
resolveNotifications({ ticketId: id, kinds: ['missed', 'failed'] });
|
|
320
|
+
return getTicket(id);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Claim a due ticket for THIS process. One guarded UPDATE: only the process that
|
|
325
|
+
* changes the row proceeds, so two hosts on one home can never start a run twice.
|
|
326
|
+
* An owned ticket can only be claimed by its owner.
|
|
327
|
+
*/
|
|
328
|
+
export function claimTicket(id, { pid = process.pid, host = hostname(), now = Date.now() } = {}) {
|
|
329
|
+
const changed = getDb().prepare(`UPDATE scheduled_runs SET status = 'firing', owner_pid = ?, owner_host = ?, updated_at = ?
|
|
330
|
+
WHERE id = ? AND status = 'scheduled' AND (owner_pid IS NULL OR (owner_pid = ? AND owner_host = ?))`)
|
|
331
|
+
.run(pid, host, iso(now), String(id), pid, host).changes;
|
|
332
|
+
return changed === 1;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Keep an owned ticket fresh (a `--wait` CLI calls this while it polls). */
|
|
336
|
+
export function heartbeatTicket(id, { now = Date.now() } = {}) {
|
|
337
|
+
return touchTicket(String(id), 'attempts = attempts', [], now) > 0;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Hand an owned ticket back to the server (the waiting terminal is going away). */
|
|
341
|
+
export function releaseTicket(id, { now = Date.now() } = {}) {
|
|
342
|
+
return getDb().prepare("UPDATE scheduled_runs SET owner_pid = NULL, owner_host = NULL, updated_at = ? WHERE id = ? AND status = 'scheduled'")
|
|
343
|
+
.run(iso(now), String(id)).changes > 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function markFired(id, pipelineId, now) {
|
|
347
|
+
touchTicket(id, "status = 'fired', pipeline_id = COALESCE(?, pipeline_id), forced = 0, queued = 0, retry_at = NULL", [pipelineId || null], now);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Record the pipeline a fired ticket became, and stamp the run's provenance columns.
|
|
352
|
+
* Safe to call repeatedly; a missing pipelines row (not written yet) is a no-op there.
|
|
353
|
+
*/
|
|
354
|
+
export function setTicketPipeline(id, pipelineId, { now = Date.now() } = {}) {
|
|
355
|
+
if (!pipelineId) return;
|
|
356
|
+
const t = getTicket(id);
|
|
357
|
+
if (!t) return;
|
|
358
|
+
if (t.pipelineId !== pipelineId) touchTicket(id, 'pipeline_id = ?', [pipelineId], now);
|
|
359
|
+
try {
|
|
360
|
+
getDb().prepare('UPDATE pipelines SET scheduled_for = ?, schedule_id = ? WHERE id = ? AND scheduled_for IS NULL')
|
|
361
|
+
.run(t.runAt, t.scheduleId || null, pipelineId);
|
|
362
|
+
} catch { /* a hand-seeded schema without the columns: provenance is decoration */ }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── run chains (spec 2026-09-21-run-chains-design.md) ────────────────────────
|
|
366
|
+
|
|
367
|
+
/** Pipeline statuses that mean "still going" for a predecessor (LIVE_PIPELINE + a parked run). */
|
|
368
|
+
const OPEN_PIPELINE = [...LIVE_PIPELINE, 'paused'];
|
|
369
|
+
const PIPELINE_REF_SQL = 'SELECT id, title, status, target, project_key, workspace_key, branch, workspace_meta, updated_at, archived_at FROM pipelines WHERE id = ?';
|
|
370
|
+
const BAD_TICKET_REASON = { canceled: 'was canceled', missed: 'was missed', failed: 'could not start', skipped: 'was skipped' };
|
|
371
|
+
const BAD_PIPELINE_REASON = { error: 'ended with an error', stopped: 'was stopped', interrupted: 'was interrupted' };
|
|
372
|
+
|
|
373
|
+
function pipelineRefRow(id) {
|
|
374
|
+
return getDb().prepare(PIPELINE_REF_SQL).get(String(id)) || null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** A predecessor by id — a ticket first, else a pipeline — as one shape for validators and cards. */
|
|
378
|
+
export function afterRefOf(id) {
|
|
379
|
+
if (typeof id !== 'string' || !id) return null;
|
|
380
|
+
const t = getTicket(id);
|
|
381
|
+
if (t) return { kind: 'ticket', id: t.id, title: t.title, status: t.status, pipelineId: t.pipelineId, projectKey: t.projectKey, workspaceId: t.workspaceId, scheduleId: t.scheduleId };
|
|
382
|
+
const p = pipelineRefRow(id);
|
|
383
|
+
if (!p) return null;
|
|
384
|
+
return { kind: 'pipeline', id: p.id, title: p.title, status: p.status, pipelineId: p.id,
|
|
385
|
+
projectKey: p.target === 'project' ? p.project_key : null, workspaceId: p.workspace_key || null, scheduleId: null };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function pipelineGate(row, { policy, isLive }) {
|
|
389
|
+
const base = { pipelineId: row.id, title: row.title, status: row.status };
|
|
390
|
+
// Archive (DELETE /api/runs/:id -> pipeline-delete.mjs) never DELETEs the row: it stamps archived_at and
|
|
391
|
+
// removes the branch, the worktree and the artifacts. For a chain that IS spec D9's removed predecessor:
|
|
392
|
+
// a `done` row whose branch is gone must strand its dependents as missed (the pinned Archive note), not
|
|
393
|
+
// start them — or fail them on a vanished ref. Archive refuses a live run, so this sits above isLive.
|
|
394
|
+
if (row.archived_at) return { state: 'gone', reason: 'was archived', ...base };
|
|
395
|
+
if ((isLive && isLive({ id: row.id, pipelineId: row.id })) || OPEN_PIPELINE.includes(row.status)) return { state: 'waiting', ...base };
|
|
396
|
+
if (row.status === 'done') return { state: 'ok', ...base };
|
|
397
|
+
if (BAD_PIPELINE_REASON[row.status]) return policy === 'any' ? { state: 'ok', ...base } : { state: 'bad', reason: BAD_PIPELINE_REASON[row.status], ...base };
|
|
398
|
+
return { state: 'waiting', ...base }; // an unknown status: keep waiting rather than guess
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Where a predecessor stands (spec §3.2): waiting (still going), ok (the gate is open),
|
|
403
|
+
* bad (it ended in a way the policy refuses), gone (no such row any more).
|
|
404
|
+
* @param {{kind:'ticket'|'pipeline', id:string}} after
|
|
405
|
+
*/
|
|
406
|
+
export function predecessorState(after, { policy = 'done', now = Date.now(), isLive = null } = {}) {
|
|
407
|
+
if (!after || !after.kind || !after.id) return { state: 'gone', reason: 'was removed', pipelineId: null, title: null, status: null };
|
|
408
|
+
if (after.kind === 'ticket') {
|
|
409
|
+
const t = getTicket(after.id);
|
|
410
|
+
if (!t) return { state: 'gone', reason: 'was removed', pipelineId: null, title: null, status: null };
|
|
411
|
+
const base = { pipelineId: t.pipelineId, title: t.title, status: t.status };
|
|
412
|
+
if (t.status === 'scheduled' || t.status === 'firing') return { state: 'waiting', ...base };
|
|
413
|
+
if (BAD_TICKET_REASON[t.status]) return { state: 'bad', reason: BAD_TICKET_REASON[t.status], ...base };
|
|
414
|
+
// fired
|
|
415
|
+
if (!t.pipelineId) {
|
|
416
|
+
if (isLive && isLive({ id: t.id, pipelineId: null })) return { state: 'waiting', ...base };
|
|
417
|
+
return now - Date.parse(t.updatedAt) < FIRED_UNKNOWN_LIVE_MS ? { state: 'waiting', ...base } : { state: 'bad', reason: 'could not start', ...base };
|
|
418
|
+
}
|
|
419
|
+
const row = pipelineRefRow(t.pipelineId);
|
|
420
|
+
if (!row) return { state: 'gone', reason: 'was removed', ...base };
|
|
421
|
+
return { ...pipelineGate(row, { policy, isLive: isLive ? (q) => isLive({ id: t.id, pipelineId: q.pipelineId }) : null }), title: t.title || row.title };
|
|
422
|
+
}
|
|
423
|
+
const row = pipelineRefRow(after.id);
|
|
424
|
+
if (!row) return { state: 'gone', reason: 'was removed', pipelineId: null, title: null, status: null };
|
|
425
|
+
return pipelineGate(row, { policy, isLive });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** The feature branch(es) a finished pipeline left, in POST /api/run's own field names. */
|
|
429
|
+
export function previousBranchesOf(pipelineId) {
|
|
430
|
+
const row = pipelineRefRow(pipelineId);
|
|
431
|
+
if (!row) return null;
|
|
432
|
+
if (row.target === 'workspace') {
|
|
433
|
+
const meta = parseJson(row.workspace_meta, null);
|
|
434
|
+
const branches = meta && meta.branches && typeof meta.branches === 'object' ? meta.branches : null;
|
|
435
|
+
if (!branches) return null;
|
|
436
|
+
const byKey = {};
|
|
437
|
+
for (const [key, b] of Object.entries(branches)) if (b && typeof b.feature === 'string' && b.feature) byKey[key] = b.feature;
|
|
438
|
+
return Object.keys(byKey).length ? { sourceBranchByKey: byKey } : null;
|
|
439
|
+
}
|
|
440
|
+
const b = parseJson(row.branch, null);
|
|
441
|
+
return b && typeof b.feature === 'string' && b.feature ? { sourceBranch: b.feature } : null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Open tickets that wait for the given run — what a cancel or an archive would strand. */
|
|
445
|
+
export function dependentsOfRun({ ticketId = null, pipelineId = null } = {}) {
|
|
446
|
+
const ids = [];
|
|
447
|
+
if (ticketId) ids.push(String(ticketId));
|
|
448
|
+
if (pipelineId) {
|
|
449
|
+
ids.push(String(pipelineId));
|
|
450
|
+
// A dependent may name the TICKET that became this pipeline (kind 'ticket'): predecessorState follows
|
|
451
|
+
// it into the same row, so an archive strands it all the same — the Archive note must name it too.
|
|
452
|
+
for (const r of getDb().prepare('SELECT id FROM scheduled_runs WHERE pipeline_id = ?').all(String(pipelineId))) ids.push(r.id);
|
|
453
|
+
}
|
|
454
|
+
if (!ids.length) return [];
|
|
455
|
+
return getDb().prepare(`SELECT id, title FROM scheduled_runs WHERE after_id IN (${ids.map(() => '?').join(', ')}) AND status IN ('scheduled', 'firing', 'missed') ORDER BY created_at ASC`)
|
|
456
|
+
.all(...ids).map((r) => ({ id: r.id, kind: 'once', title: r.title || null }));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const CHAIN_DEPTH_CAP = 100;
|
|
460
|
+
|
|
461
|
+
/** True when following `after` upward reaches `selfId` (spec D8). */
|
|
462
|
+
function chainReaches(after, selfId) {
|
|
463
|
+
let cur = after;
|
|
464
|
+
for (let depth = 0; cur && cur.kind === 'ticket' && depth < CHAIN_DEPTH_CAP; depth++) {
|
|
465
|
+
if (cur.id === selfId) return true;
|
|
466
|
+
const t = getTicket(cur.id);
|
|
467
|
+
cur = t ? t.after : null;
|
|
468
|
+
}
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The base branches a run's PR can target along its chain, root first:
|
|
474
|
+
* [root, …, directSource]. Walks back through the tickets that started each run
|
|
475
|
+
* "from its branch" (source_from_previous) while the predecessor's feature branch
|
|
476
|
+
* IS the run's source; a run started any other way is the root. A missing link
|
|
477
|
+
* (a purged ticket, a gone row), a mismatch or a repeat ends the walk, so a run
|
|
478
|
+
* outside a chain answers [its own source]; no recorded source answers [].
|
|
479
|
+
*/
|
|
480
|
+
export function chainBaseBranchesOf(pipelineId) {
|
|
481
|
+
const branchOf = (row) => {
|
|
482
|
+
const b = row && row.target !== 'workspace' ? parseJson(row.branch, null) : null;
|
|
483
|
+
return b && typeof b === 'object' ? b : null;
|
|
484
|
+
};
|
|
485
|
+
const row = pipelineRefRow(pipelineId);
|
|
486
|
+
const source = branchOf(row)?.source;
|
|
487
|
+
if (typeof source !== 'string' || !source) return [];
|
|
488
|
+
const chain = [source];
|
|
489
|
+
const seen = new Set([row.id]);
|
|
490
|
+
let cur = row.id;
|
|
491
|
+
for (let depth = 0; depth < CHAIN_DEPTH_CAP; depth++) {
|
|
492
|
+
const t = getDb().prepare('SELECT after_kind, after_id, source_from_previous FROM scheduled_runs WHERE pipeline_id = ? ORDER BY updated_at DESC LIMIT 1').get(cur);
|
|
493
|
+
if (!t || !t.source_from_previous || !t.after_id) break;
|
|
494
|
+
const predId = t.after_kind === 'ticket' ? getTicket(t.after_id)?.pipelineId : t.after_id;
|
|
495
|
+
if (!predId || seen.has(predId)) break;
|
|
496
|
+
const pred = branchOf(pipelineRefRow(predId));
|
|
497
|
+
if (!pred || pred.feature !== chain[0] || typeof pred.source !== 'string' || !pred.source) break;
|
|
498
|
+
chain.unshift(pred.source);
|
|
499
|
+
seen.add(predId);
|
|
500
|
+
cur = predId;
|
|
501
|
+
}
|
|
502
|
+
return chain;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The ONE validator for a predecessor reference (server, CLI, Ask parent — spec §3.4).
|
|
507
|
+
* `after.kind` is advisory: the row decides. Accepts a predecessor that is waiting or done.
|
|
508
|
+
*/
|
|
509
|
+
export function resolveAfterRef(after, { projectDir = null, workspaceId = null, policy = 'done', selfId = null, isLive = null } = {}) {
|
|
510
|
+
const id = after && typeof after === 'object' && typeof after.id === 'string' ? after.id.trim() : '';
|
|
511
|
+
if (!id || !after || !AFTER_KINDS.includes(String(after.kind))) return { ok: false, error: 'after must be { kind: ticket | pipeline, id }' };
|
|
512
|
+
if (id.startsWith('sch_')) return { ok: false, error: 'after a repeating schedule is not supported — give the id of one of its runs' };
|
|
513
|
+
const ref = afterRefOf(id);
|
|
514
|
+
if (!ref) return { ok: false, error: `no run or scheduled run has id ${id}` };
|
|
515
|
+
if (ref.scheduleId) return { ok: false, error: 'after a repeating schedule is not supported — give the id of one of its runs' };
|
|
516
|
+
const name = `‘${ref.title || id.slice(0, 8)}’`;
|
|
517
|
+
const wantKey = projectDir ? projectKey(projectDir) : null;
|
|
518
|
+
if (workspaceId) {
|
|
519
|
+
if (!ref.workspaceId) return { ok: false, error: `${name} targets a project; this run targets a workspace` };
|
|
520
|
+
if (ref.workspaceId !== workspaceId) return { ok: false, error: `${name} targets another workspace; this run targets ${workspaceId}` };
|
|
521
|
+
} else {
|
|
522
|
+
if (ref.workspaceId) return { ok: false, error: `${name} targets a workspace; this run targets a project` };
|
|
523
|
+
if (wantKey && ref.projectKey !== wantKey) return { ok: false, error: `${name} targets another project; this run targets ${projectDir}` };
|
|
524
|
+
}
|
|
525
|
+
if (selfId && ref.kind === 'ticket') {
|
|
526
|
+
if (ref.id === selfId) return { ok: false, error: `${name} is this run` };
|
|
527
|
+
if (chainReaches({ kind: 'ticket', id: ref.id }, selfId)) return { ok: false, error: `${name} already waits for this run` };
|
|
528
|
+
}
|
|
529
|
+
const p = predecessorState({ kind: ref.kind, id: ref.id }, { policy, isLive });
|
|
530
|
+
if (p.state === 'bad' || p.state === 'gone') return { ok: false, error: `${name} ${p.reason} — nothing to wait for` };
|
|
531
|
+
return { ok: true, after: { kind: ref.kind, id: ref.id, title: ref.title || null, status: ref.status, pipelineId: ref.pipelineId || null } };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ── schedules (recurring parents) ────────────────────────────────────────────
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Create a recurring schedule and materialise its first occurrence.
|
|
538
|
+
* @returns {{schedule: object, ticket: object|null}}
|
|
539
|
+
* @throws {Error} on an invalid rule
|
|
540
|
+
*/
|
|
541
|
+
export function createSchedule({
|
|
542
|
+
title = null, projectDir = null, workspaceId = null, request, rule, overlap = 'skip',
|
|
543
|
+
maxFailures = 3, ifMissed = 'run', graceMin = 360, id = `sch_${randomBytes(4).toString('hex')}`,
|
|
544
|
+
askThreadId = null, askCardId = null, now = Date.now(),
|
|
545
|
+
}) {
|
|
546
|
+
const norm = normalizeRule(rule, { todayLocal: rule?.anchor || null });
|
|
547
|
+
if (!norm.ok) throw new Error(norm.error);
|
|
548
|
+
const tc = targetCols({ projectDir, workspaceId });
|
|
549
|
+
const ts = iso(now);
|
|
550
|
+
tx(() => {
|
|
551
|
+
getDb().prepare(`
|
|
552
|
+
INSERT INTO schedules (id, title, project_key, project_dir, workspace_id, request, rule, overlap, max_failures,
|
|
553
|
+
if_missed, grace_min, status, ask_thread_id, ask_card_id, created_at, updated_at)
|
|
554
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
|
|
555
|
+
`).run(id, title, tc.project_key, tc.project_dir, tc.workspace_id, JSON.stringify(request || {}), JSON.stringify(norm.rule),
|
|
556
|
+
normPolicy(overlap, OVERLAP_POLICIES, 'skip'), normMaxFailures(maxFailures), normPolicy(ifMissed, MISSED_POLICIES, 'run'),
|
|
557
|
+
normGrace(graceMin), askThreadId, askCardId, ts, ts);
|
|
558
|
+
});
|
|
559
|
+
const ticket = materializeNext(id, { now });
|
|
560
|
+
return { schedule: getSchedule(id), ticket };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function getSchedule(id, opts = {}) {
|
|
564
|
+
return rowToSchedule(getDb().prepare('SELECT * FROM schedules WHERE id = ?').get(String(id)), opts);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export function listSchedules({ projectDir = null, workspaceId = null, includeEnded = true } = {}) {
|
|
568
|
+
const where = [];
|
|
569
|
+
const args = [];
|
|
570
|
+
if (projectDir) { where.push('project_key = ?'); args.push(projectKey(projectDir)); }
|
|
571
|
+
if (workspaceId) { where.push('workspace_id = ?'); args.push(workspaceId); }
|
|
572
|
+
if (!includeEnded) where.push("status != 'ended'");
|
|
573
|
+
return getDb().prepare(`SELECT * FROM schedules ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at DESC`)
|
|
574
|
+
.all(...args).map((r) => rowToSchedule(r));
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function dropPendingTickets(scheduleId) {
|
|
578
|
+
getDb().prepare("DELETE FROM scheduled_runs WHERE schedule_id = ? AND status = 'scheduled' AND owner_pid IS NULL").run(scheduleId);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Ensure the series has its next occurrence as a ticket. No-op when one is already
|
|
583
|
+
* open or the schedule is not active. When the rule is exhausted the schedule ENDS.
|
|
584
|
+
* Catch-up is bounded by construction: the next slot is always computed from `now`,
|
|
585
|
+
* so a backlog is never replayed.
|
|
586
|
+
* @returns {object|null} the open ticket
|
|
587
|
+
*/
|
|
588
|
+
export function materializeNext(scheduleId, { now = Date.now() } = {}) {
|
|
589
|
+
const s = getSchedule(scheduleId, { withRequest: true });
|
|
590
|
+
if (!s || s.status !== 'active') return null;
|
|
591
|
+
const open = getDb().prepare(`SELECT * FROM scheduled_runs WHERE schedule_id = ? AND status IN (${OPEN_TICKET.map(() => '?').join(', ')}) AND forced = 0 ORDER BY run_at ASC LIMIT 1`)
|
|
592
|
+
.get(scheduleId, ...OPEN_TICKET);
|
|
593
|
+
if (open) return rowToTicket(open);
|
|
594
|
+
const next = nextOccurrence(s.rule, now, { firedCount: s.runsCount });
|
|
595
|
+
if (next == null) {
|
|
596
|
+
getDb().prepare("UPDATE schedules SET status = 'ended', next_run_at = NULL, updated_at = ? WHERE id = ?").run(iso(now), scheduleId);
|
|
597
|
+
addNotification({ kind: 'ended', severity: 'info', scheduleId, projectDir: s.projectDir, title: s.title, message: 'reached its end and will not run again.', now: new Date(now) });
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
const ticket = createTicket({
|
|
601
|
+
scheduleId, title: s.title, projectDir: s.projectDir, workspaceId: s.workspaceId, runAtMs: next,
|
|
602
|
+
request: s.request, ifMissed: s.ifMissed, graceMin: s.graceMin, now,
|
|
603
|
+
});
|
|
604
|
+
getDb().prepare('UPDATE schedules SET next_run_at = ?, updated_at = ? WHERE id = ?').run(iso(next), iso(now), scheduleId);
|
|
605
|
+
return ticket;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Edit a schedule. A pending occurrence is REPLACED by one computed from the new
|
|
610
|
+
* rule; a run that already started is untouched.
|
|
611
|
+
* @throws {Error} on an invalid rule
|
|
612
|
+
*/
|
|
613
|
+
export function updateSchedule(id, patch = {}, { now = Date.now() } = {}) {
|
|
614
|
+
const s = getSchedule(id, { withRequest: true });
|
|
615
|
+
if (!s) return null;
|
|
616
|
+
const sets = [];
|
|
617
|
+
const args = [];
|
|
618
|
+
if (patch.title !== undefined) { sets.push('title = ?'); args.push(patch.title ? String(patch.title) : null); }
|
|
619
|
+
if (patch.rule !== undefined) {
|
|
620
|
+
const norm = normalizeRule({ anchor: s.rule?.anchor, ...patch.rule }, { todayLocal: s.rule?.anchor || null });
|
|
621
|
+
if (!norm.ok) throw new Error(norm.error);
|
|
622
|
+
sets.push('rule = ?'); args.push(JSON.stringify(norm.rule));
|
|
623
|
+
}
|
|
624
|
+
if (patch.overlap !== undefined) {
|
|
625
|
+
if (!OVERLAP_POLICIES.includes(patch.overlap)) throw new Error(`overlap must be one of ${OVERLAP_POLICIES.join(' | ')}`);
|
|
626
|
+
sets.push('overlap = ?'); args.push(patch.overlap);
|
|
627
|
+
}
|
|
628
|
+
if (patch.maxFailures !== undefined) { sets.push('max_failures = ?'); args.push(normMaxFailures(patch.maxFailures, s.maxFailures)); }
|
|
629
|
+
if (patch.ifMissed !== undefined) {
|
|
630
|
+
if (!MISSED_POLICIES.includes(patch.ifMissed)) throw new Error(`ifMissed must be one of ${MISSED_POLICIES.join(' | ')}`);
|
|
631
|
+
sets.push('if_missed = ?'); args.push(patch.ifMissed);
|
|
632
|
+
}
|
|
633
|
+
if (patch.graceMin !== undefined) { sets.push('grace_min = ?'); args.push(normGrace(patch.graceMin, s.graceMin)); }
|
|
634
|
+
if (patch.request !== undefined) { sets.push('request = ?'); args.push(JSON.stringify(patch.request || {})); }
|
|
635
|
+
if (!sets.length) return s;
|
|
636
|
+
tx(() => {
|
|
637
|
+
getDb().prepare(`UPDATE schedules SET ${sets.join(', ')}, updated_at = ? WHERE id = ?`).run(...args, iso(now), id);
|
|
638
|
+
dropPendingTickets(id);
|
|
639
|
+
});
|
|
640
|
+
materializeNext(id, { now });
|
|
641
|
+
return getSchedule(id);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Pause a series: its pending occurrence is dropped, nothing fires until resume. */
|
|
645
|
+
export function pauseSchedule(id, { reason = 'user', now = Date.now() } = {}) {
|
|
646
|
+
const changed = getDb().prepare("UPDATE schedules SET status = 'paused', pause_reason = ?, next_run_at = NULL, updated_at = ? WHERE id = ? AND status = 'active'")
|
|
647
|
+
.run(reason, iso(now), String(id)).changes;
|
|
648
|
+
if (!changed) return null;
|
|
649
|
+
dropPendingTickets(id);
|
|
650
|
+
return getSchedule(id);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Resume a paused series: the streak resets, its alarms resolve, the next slot appears. */
|
|
654
|
+
export function resumeSchedule(id, { now = Date.now() } = {}) {
|
|
655
|
+
const changed = getDb().prepare("UPDATE schedules SET status = 'active', pause_reason = NULL, failure_streak = 0, updated_at = ? WHERE id = ? AND status = 'paused'")
|
|
656
|
+
.run(iso(now), String(id)).changes;
|
|
657
|
+
if (!changed) return null;
|
|
658
|
+
resolveNotifications({ scheduleId: id });
|
|
659
|
+
materializeNext(id, { now });
|
|
660
|
+
return getSchedule(id);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Skip the pending occurrence of a series and materialise the one after it. */
|
|
664
|
+
export function skipNext(id, { now = Date.now() } = {}) {
|
|
665
|
+
const s = getSchedule(id);
|
|
666
|
+
if (!s || s.status !== 'active') return null;
|
|
667
|
+
const row = getDb().prepare("SELECT * FROM scheduled_runs WHERE schedule_id = ? AND status = 'scheduled' AND forced = 0 ORDER BY run_at ASC LIMIT 1").get(id);
|
|
668
|
+
if (!row) return s;
|
|
669
|
+
touchTicket(row.id, "status = 'skipped', fail_reason = 'skipped by user'", [], now);
|
|
670
|
+
materializeNext(id, { now: Math.max(now, Date.parse(row.run_at)) });
|
|
671
|
+
return getSchedule(id);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Start one extra occurrence now; the series itself does not shift. */
|
|
675
|
+
export function runScheduleNow(id, { now = Date.now() } = {}) {
|
|
676
|
+
const s = getSchedule(id, { withRequest: true });
|
|
677
|
+
if (!s || s.status === 'ended') return null;
|
|
678
|
+
return createTicket({
|
|
679
|
+
scheduleId: id, title: s.title, projectDir: s.projectDir, workspaceId: s.workspaceId, runAtMs: now,
|
|
680
|
+
request: s.request, ifMissed: 'run', graceMin: s.graceMin, forced: true, now,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Delete a series with its tickets and staged extras. */
|
|
685
|
+
export function deleteSchedule(id) {
|
|
686
|
+
const changed = getDb().prepare('DELETE FROM schedules WHERE id = ?').run(String(id)).changes;
|
|
687
|
+
if (changed) {
|
|
688
|
+
getDb().prepare('DELETE FROM scheduled_runs WHERE schedule_id = ?').run(String(id)); // FK cascade may be off
|
|
689
|
+
resolveNotifications({ scheduleId: id });
|
|
690
|
+
removeStage(id);
|
|
691
|
+
}
|
|
692
|
+
return changed > 0;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Cancel every open schedule/ticket of a removed project or workspace. @returns {number} */
|
|
696
|
+
export function cancelForTarget({ projectDir = null, workspaceId = null } = {}) {
|
|
697
|
+
let n = 0;
|
|
698
|
+
for (const s of listSchedules({ projectDir, workspaceId })) { if (deleteSchedule(s.id)) n++; }
|
|
699
|
+
for (const t of listTickets({ projectDir, workspaceId, oneShotOnly: true })) { if (cancelTicket(t.id)) n++; }
|
|
700
|
+
return n;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Open schedules + one-shot tickets that depend on a workflow id (for the archive warning). */
|
|
704
|
+
export function dependentsOfWorkflow(workflowId) {
|
|
705
|
+
const out = [];
|
|
706
|
+
for (const s of listSchedules({ includeEnded: false })) if (s.summary.workflowId === workflowId) out.push({ id: s.id, kind: 'recurring', title: s.title });
|
|
707
|
+
for (const t of listTickets({ oneShotOnly: true })) if (t.summary.workflowId === workflowId) out.push({ id: t.id, kind: 'once', title: t.title });
|
|
708
|
+
return out;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ── failure streak ───────────────────────────────────────────────────────────
|
|
712
|
+
|
|
713
|
+
function bumpFailure(scheduleId, now) {
|
|
714
|
+
const s = getSchedule(scheduleId);
|
|
715
|
+
if (!s) return;
|
|
716
|
+
const streak = s.failureStreak + 1;
|
|
717
|
+
getDb().prepare('UPDATE schedules SET failure_streak = ?, updated_at = ? WHERE id = ?').run(streak, iso(now), scheduleId);
|
|
718
|
+
if (s.status === 'active' && s.maxFailures > 0 && streak >= s.maxFailures) {
|
|
719
|
+
pauseSchedule(scheduleId, { reason: 'failure_streak', now });
|
|
720
|
+
addNotification({
|
|
721
|
+
kind: 'paused', scheduleId, projectDir: s.projectDir, title: s.title,
|
|
722
|
+
message: `paused itself after ${streak} failure${streak === 1 ? '' : 's'} in a row.`, now: new Date(now),
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function setLastResult(scheduleId, result, now, { resetStreak = false } = {}) {
|
|
728
|
+
if (!scheduleId) return;
|
|
729
|
+
getDb().prepare(`UPDATE schedules SET last_result = ?${resetStreak ? ', failure_streak = 0' : ''}, updated_at = ? WHERE id = ?`).run(result, iso(now), scheduleId);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// ── the due loop ─────────────────────────────────────────────────────────────
|
|
733
|
+
|
|
734
|
+
function ownerAlive(t, { host, pidAlive, now }) {
|
|
735
|
+
if (t.ownerPid == null) return false;
|
|
736
|
+
if (t.ownerHost === host) return pidAlive(t.ownerPid);
|
|
737
|
+
return now - Date.parse(t.updatedAt) < FOREIGN_OWNER_FRESH_MS;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function previousStillRunning(t, { now, isLive }) {
|
|
741
|
+
const rows = getDb().prepare("SELECT id, pipeline_id, updated_at FROM scheduled_runs WHERE schedule_id = ? AND status = 'fired' AND id != ? ORDER BY updated_at DESC LIMIT 5")
|
|
742
|
+
.all(t.scheduleId, t.id);
|
|
743
|
+
for (const r of rows) {
|
|
744
|
+
if (isLive && isLive({ id: r.id, pipelineId: r.pipeline_id })) return true;
|
|
745
|
+
if (r.pipeline_id) {
|
|
746
|
+
const p = getDb().prepare('SELECT status FROM pipelines WHERE id = ?').get(r.pipeline_id);
|
|
747
|
+
if (p && LIVE_PIPELINE.includes(p.status)) return true;
|
|
748
|
+
} else if (now - Date.parse(r.updated_at) < FIRED_UNKNOWN_LIVE_MS) return true;
|
|
749
|
+
}
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function effectiveGraceMs(t, schedule) {
|
|
754
|
+
let grace = t.graceMin * 60_000;
|
|
755
|
+
if (schedule?.rule) {
|
|
756
|
+
const runAt = Date.parse(t.runAt);
|
|
757
|
+
const next = nextOccurrence(schedule.rule, runAt, { firedCount: 0 });
|
|
758
|
+
if (next != null) grace = Math.min(grace, next - runAt);
|
|
759
|
+
}
|
|
760
|
+
return grace;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function miss(t, schedule, now, why) {
|
|
764
|
+
touchTicket(t.id, "status = 'missed', fail_reason = ?", [why], now);
|
|
765
|
+
addNotification({
|
|
766
|
+
kind: 'missed', scheduleId: t.scheduleId, ticketId: t.id, projectDir: t.projectDir, title: t.title,
|
|
767
|
+
message: `was due at ${t.runAt}. ${why}`, now: new Date(now),
|
|
768
|
+
});
|
|
769
|
+
if (schedule) { setLastResult(schedule.id, 'missed', now); bumpFailure(schedule.id, now); materializeNext(schedule.id, { now }); }
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** An after-ticket whose predecessor ended in a way its policy refuses, or vanished (spec §3.3). */
|
|
773
|
+
function missAfter(t, now, p) {
|
|
774
|
+
touchTicket(t.id, "status = 'missed', fail_reason = ?", [`The run before it ${p.reason}.`], now);
|
|
775
|
+
addNotification({
|
|
776
|
+
kind: 'missed', ticketId: t.id, projectDir: t.projectDir, title: t.title,
|
|
777
|
+
message: `was waiting for ‘${p.title || 'the run before it'}’, which ${p.reason}.`, now: new Date(now),
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** Tickets whose time has come (and whose retry delay, if any, has passed). */
|
|
782
|
+
export function dueTickets({ now = Date.now() } = {}) {
|
|
783
|
+
const ts = iso(now);
|
|
784
|
+
// A retry delay holds EVERY ticket, a forced one (Run now) included — otherwise a
|
|
785
|
+
// Run now that hit a transient error would be re-tried on every tick.
|
|
786
|
+
return getDb().prepare(`SELECT * FROM scheduled_runs WHERE status = 'scheduled'
|
|
787
|
+
AND (forced = 1 OR run_at <= ? OR after_id IS NOT NULL) AND (retry_at IS NULL OR retry_at <= ?) ORDER BY run_at ASC`)
|
|
788
|
+
.all(ts, ts).map((r) => rowToTicket(r, { withRequest: true }));
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Process every due ticket once. The host supplies:
|
|
793
|
+
* start(ticket) -> { ok:true, pipelineId? } | { ok:false, error, transient? }
|
|
794
|
+
* isLive({id,pipelineId}) -> boolean (optional: the host's in-memory run view)
|
|
795
|
+
* Returns a summary { fired, missed, skipped, failed, retried, waiting }.
|
|
796
|
+
*/
|
|
797
|
+
export async function runDueTickets({
|
|
798
|
+
now = Date.now(), start, isLive = null, pid = process.pid, host = hostname(), pidAlive = defaultPidAlive,
|
|
799
|
+
onlyOwned = false, staggerMs = 0, sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
800
|
+
} = {}) {
|
|
801
|
+
const out = { fired: [], missed: [], skipped: [], failed: [], retried: [], waiting: [] };
|
|
802
|
+
let startedAny = false;
|
|
803
|
+
for (const t of dueTickets({ now })) {
|
|
804
|
+
const mine = t.ownerPid === pid && t.ownerHost === host;
|
|
805
|
+
if (onlyOwned && !mine) continue;
|
|
806
|
+
if (!mine && t.ownerPid != null) {
|
|
807
|
+
if (ownerAlive(t, { host, pidAlive, now })) { out.waiting.push(t.id); continue; }
|
|
808
|
+
releaseTicket(t.id, { now }); // the waiting terminal died: the ticket falls back to us
|
|
809
|
+
t.ownerPid = null;
|
|
810
|
+
}
|
|
811
|
+
const schedule = t.scheduleId ? getSchedule(t.scheduleId) : null;
|
|
812
|
+
if (t.scheduleId && !t.forced && (!schedule || schedule.status !== 'active')) {
|
|
813
|
+
touchTicket(t.id, "status = 'canceled', fail_reason = 'schedule is not active'", [], now);
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (t.after) {
|
|
818
|
+
const p = predecessorState(t.after, { policy: t.after.policy, now, isLive });
|
|
819
|
+
if (!t.forced) {
|
|
820
|
+
if (p.state === 'waiting') { out.waiting.push(t.id); continue; }
|
|
821
|
+
if (p.state !== 'ok') { missAfter(t, now, p); out.missed.push(t.id); continue; }
|
|
822
|
+
}
|
|
823
|
+
// D11: due from now — the late / retry maths below start here, never at the sentinel.
|
|
824
|
+
if (t.attempts === 0) { touchTicket(t.id, 'run_at = ?', [iso(now)], now); t.runAt = iso(now); }
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const late = now - Date.parse(t.runAt);
|
|
828
|
+
if (!t.forced && !t.queued && t.attempts === 0 && late > ON_TIME_SLACK_MS) {
|
|
829
|
+
if (t.ifMissed === 'skip') { miss(t, schedule, now, 'Worca was not running, and this schedule skips missed slots.'); out.missed.push(t.id); continue; }
|
|
830
|
+
if (late > effectiveGraceMs(t, schedule)) { miss(t, schedule, now, 'Worca was not running.'); out.missed.push(t.id); continue; }
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (schedule && !t.forced && schedule.overlap !== 'start' && previousStillRunning(t, { now, isLive })) {
|
|
834
|
+
if (schedule.overlap === 'queue') {
|
|
835
|
+
if (!t.queued) touchTicket(t.id, 'queued = 1', [], now);
|
|
836
|
+
out.waiting.push(t.id);
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
touchTicket(t.id, "status = 'skipped', fail_reason = 'the previous run was still going'", [], now);
|
|
840
|
+
addNotification({
|
|
841
|
+
kind: 'skipped', severity: 'info', scheduleId: t.scheduleId, ticketId: t.id, projectDir: t.projectDir, title: t.title,
|
|
842
|
+
message: 'was skipped because the previous run was still going.', now: new Date(now),
|
|
843
|
+
});
|
|
844
|
+
setLastResult(schedule.id, 'skipped', now);
|
|
845
|
+
materializeNext(schedule.id, { now });
|
|
846
|
+
out.skipped.push(t.id);
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (!claimTicket(t.id, { pid, host, now })) continue; // another process won the race
|
|
851
|
+
if (startedAny && staggerMs > 0) await sleep(staggerMs);
|
|
852
|
+
startedAny = true;
|
|
853
|
+
|
|
854
|
+
let res;
|
|
855
|
+
try { res = await start(t); }
|
|
856
|
+
catch (err) { res = { ok: false, error: err && err.message ? err.message : String(err), transient: false }; }
|
|
857
|
+
|
|
858
|
+
if (res && res.ok) {
|
|
859
|
+
markFired(t.id, res.pipelineId || null, now);
|
|
860
|
+
if (res.pipelineId) setTicketPipeline(t.id, res.pipelineId, { now });
|
|
861
|
+
if (!t.forced && late > LATE_NOTICE_MS) {
|
|
862
|
+
addNotification({
|
|
863
|
+
kind: 'late', severity: 'info', scheduleId: t.scheduleId, ticketId: t.id, projectDir: t.projectDir, title: t.title,
|
|
864
|
+
message: `started late. It was due at ${t.runAt}.`, now: new Date(now),
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
if (schedule) {
|
|
868
|
+
getDb().prepare('UPDATE schedules SET runs_count = runs_count + 1, updated_at = ? WHERE id = ?').run(iso(now), schedule.id);
|
|
869
|
+
materializeNext(schedule.id, { now });
|
|
870
|
+
}
|
|
871
|
+
out.fired.push(t.id);
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const reason = (res && res.error) || 'the run could not be started';
|
|
876
|
+
const backoff = RETRY_BACKOFF_MIN[Math.min(t.attempts, RETRY_BACKOFF_MIN.length - 1)] * 60_000;
|
|
877
|
+
const deadline = Date.parse(t.runAt) + Math.max(effectiveGraceMs(t, schedule), ON_TIME_SLACK_MS);
|
|
878
|
+
if (res && res.transient && now + backoff <= deadline) {
|
|
879
|
+
touchTicket(t.id, "status = 'scheduled', attempts = attempts + 1, retry_at = ?, fail_reason = ?, owner_pid = NULL, owner_host = NULL",
|
|
880
|
+
[iso(now + backoff), reason], now);
|
|
881
|
+
if (t.attempts === 0) {
|
|
882
|
+
addNotification({
|
|
883
|
+
kind: 'retrying', severity: 'info', scheduleId: t.scheduleId, ticketId: t.id, projectDir: t.projectDir, title: t.title,
|
|
884
|
+
message: `could not start yet (${reason}). Worca will retry.`, now: new Date(now),
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
out.retried.push(t.id);
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
touchTicket(t.id, "status = 'failed', fail_reason = ?, forced = 0", [reason], now);
|
|
891
|
+
addNotification({
|
|
892
|
+
kind: 'failed', scheduleId: t.scheduleId, ticketId: t.id, projectDir: t.projectDir, title: t.title,
|
|
893
|
+
message: `could not start: ${reason}`, now: new Date(now),
|
|
894
|
+
});
|
|
895
|
+
if (schedule) { setLastResult(schedule.id, 'failed', now); bumpFailure(schedule.id, now); materializeNext(schedule.id, { now }); }
|
|
896
|
+
else removeStage(t.id);
|
|
897
|
+
out.failed.push(t.id);
|
|
898
|
+
}
|
|
899
|
+
return out;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Record how a fired ticket's run ended. `done` resets the series' streak and posts a
|
|
904
|
+
* quiet item; `error` counts towards the streak; a forced `paused` (it carries a
|
|
905
|
+
* reason) notifies but does not count; `stopped` (by the user) is silent.
|
|
906
|
+
*/
|
|
907
|
+
export function recordOutcome(ticketId, { status, pipelineId = null, reason = null, detail = null, now = Date.now() } = {}) {
|
|
908
|
+
const t = getTicket(ticketId);
|
|
909
|
+
if (!t || t.status !== 'fired') return;
|
|
910
|
+
if (pipelineId) setTicketPipeline(ticketId, pipelineId, { now });
|
|
911
|
+
const base = { scheduleId: t.scheduleId, ticketId, pipelineId: pipelineId || t.pipelineId, projectDir: t.projectDir, title: t.title, now: new Date(now) };
|
|
912
|
+
if (status === 'done') {
|
|
913
|
+
addNotification({ ...base, kind: 'completed', severity: 'info', message: 'finished.' });
|
|
914
|
+
setLastResult(t.scheduleId, 'completed', now, { resetStreak: true });
|
|
915
|
+
} else if (status === 'error') {
|
|
916
|
+
addNotification({ ...base, kind: 'run_error', message: `ended with an error${detail ? `: ${detail}` : '.'}` });
|
|
917
|
+
if (t.scheduleId) { setLastResult(t.scheduleId, 'error', now); bumpFailure(t.scheduleId, now); }
|
|
918
|
+
} else if (status === 'paused') {
|
|
919
|
+
if (reason) addNotification({ ...base, kind: 'run_paused', message: `paused (${String(reason).replace(/_/g, ' ')})${detail ? `: ${detail}` : '.'}` });
|
|
920
|
+
setLastResult(t.scheduleId, 'paused', now);
|
|
921
|
+
} else if (status === 'stopped') {
|
|
922
|
+
setLastResult(t.scheduleId, 'stopped', now);
|
|
923
|
+
} else return;
|
|
924
|
+
if (!t.scheduleId && status !== 'paused') removeStage(ticketId);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Boot/tick janitor: a ticket stuck in `firing` with no pipeline and a dead owner goes
|
|
929
|
+
* back to `scheduled`; every active series gets its next slot. @returns {number} recovered
|
|
930
|
+
*/
|
|
931
|
+
export function recoverScheduler({ now = Date.now(), host = hostname(), pidAlive = defaultPidAlive } = {}) {
|
|
932
|
+
let n = 0;
|
|
933
|
+
const rows = getDb().prepare("SELECT * FROM scheduled_runs WHERE status = 'firing' AND pipeline_id IS NULL").all();
|
|
934
|
+
for (const r of rows) {
|
|
935
|
+
const dead = r.owner_host === host ? !pidAlive(r.owner_pid) : now - Date.parse(r.updated_at) > STALE_FIRING_MS;
|
|
936
|
+
if (!dead) continue;
|
|
937
|
+
n += getDb().prepare("UPDATE scheduled_runs SET status = 'scheduled', owner_pid = NULL, owner_host = NULL, updated_at = ? WHERE id = ? AND status = 'firing'")
|
|
938
|
+
.run(iso(now), r.id).changes;
|
|
939
|
+
}
|
|
940
|
+
for (const s of getDb().prepare("SELECT id FROM schedules WHERE status = 'active'").all()) materializeNext(s.id, { now });
|
|
941
|
+
return n;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** Drop ended tickets and ended schedules past the retention window. */
|
|
945
|
+
export function purgeScheduler({ days = TICKET_RETENTION_DAYS, now = Date.now() } = {}) {
|
|
946
|
+
const cutoff = iso(now - days * 86400000);
|
|
947
|
+
const old = getDb().prepare("SELECT id, schedule_id FROM scheduled_runs WHERE status IN ('fired', 'canceled', 'skipped', 'failed') AND updated_at < ?").all(cutoff);
|
|
948
|
+
for (const r of old) { if (!r.schedule_id) removeStage(r.id); }
|
|
949
|
+
const tickets = getDb().prepare("DELETE FROM scheduled_runs WHERE status IN ('fired', 'canceled', 'skipped', 'failed') AND updated_at < ?").run(cutoff).changes;
|
|
950
|
+
let schedules = 0;
|
|
951
|
+
for (const s of getDb().prepare("SELECT id FROM schedules WHERE status = 'ended' AND updated_at < ?").all(cutoff)) { if (deleteSchedule(s.id)) schedules++; }
|
|
952
|
+
return { tickets, schedules };
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** A cheap change probe over both tables (another process may have written them). */
|
|
956
|
+
export function scheduleSignature() {
|
|
957
|
+
const db = getDb();
|
|
958
|
+
const a = db.prepare('SELECT COUNT(*) AS n, COALESCE(MAX(updated_at), \'\') AS m FROM scheduled_runs').get();
|
|
959
|
+
const b = db.prepare('SELECT COUNT(*) AS n, COALESCE(MAX(updated_at), \'\') AS m FROM schedules').get();
|
|
960
|
+
return `${a.n}:${a.m}|${b.n}:${b.m}`;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Mark a claimed ticket fired — for a host that drives the run itself (the `--wait`
|
|
965
|
+
* CLI); runDueTickets does this for its own starts.
|
|
966
|
+
*/
|
|
967
|
+
export function markTicketFired(id, { pipelineId = null, now = Date.now() } = {}) {
|
|
968
|
+
markFired(String(id), pipelineId, now);
|
|
969
|
+
if (pipelineId) setTicketPipeline(id, pipelineId, { now });
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** Counts for nav badges: { scheduled, recurring, missed }. */
|
|
973
|
+
export function scheduleCounts() {
|
|
974
|
+
const db = getDb();
|
|
975
|
+
return {
|
|
976
|
+
scheduled: db.prepare("SELECT COUNT(*) AS n FROM scheduled_runs WHERE status IN ('scheduled', 'firing')").get().n,
|
|
977
|
+
missed: db.prepare("SELECT COUNT(*) AS n FROM scheduled_runs WHERE status = 'missed'").get().n,
|
|
978
|
+
recurring: db.prepare("SELECT COUNT(*) AS n FROM schedules WHERE status != 'ended'").get().n,
|
|
979
|
+
};
|
|
980
|
+
}
|