@worca/app 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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,494 @@
|
|
|
1
|
+
// src/cli/schedule.mjs
|
|
2
|
+
// The CLI side of scheduled runs: `worca … --at/--every/--cron` writes a launch ticket
|
|
3
|
+
// (or a recurring schedule) straight into the shared database — no running server is
|
|
4
|
+
// needed to SCHEDULE, only to START — plus the `worca schedule` management verbs and the
|
|
5
|
+
// `--wait` foreground mode, where this terminal owns its ticket and starts it itself.
|
|
6
|
+
|
|
7
|
+
import { randomUUID, randomBytes } from 'node:crypto';
|
|
8
|
+
import { hostname } from 'node:os';
|
|
9
|
+
import { join, basename } from 'node:path';
|
|
10
|
+
import { mkdir, copyFile } from 'node:fs/promises';
|
|
11
|
+
import process from 'node:process';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
createTicket, getTicket, listTickets, updateTicket, cancelTicket, requestRunNow, claimTicket,
|
|
15
|
+
heartbeatTicket, releaseTicket, markTicketFired, setTicketPipeline, recordOutcome,
|
|
16
|
+
createSchedule, listSchedules, pauseSchedule, resumeSchedule, skipNext, runScheduleNow,
|
|
17
|
+
deleteSchedule, scheduleStageDir,
|
|
18
|
+
resolveAfterRef, afterRefOf, predecessorState, previousBranchesOf,
|
|
19
|
+
} from '../core/scheduler.mjs';
|
|
20
|
+
import { getDb } from '../core/db.mjs';
|
|
21
|
+
import { listNotifications, markAllRead, unreadCount, addNotification } from '../core/notifications.mjs';
|
|
22
|
+
import { scheduleDefaults } from '../core/settings.mjs';
|
|
23
|
+
import { readUiInstance, probeUi } from '../core/ui-instance.mjs';
|
|
24
|
+
import {
|
|
25
|
+
parseAt, parseEvery, parseCron, normalizeRule, nextOccurrence, describeRule, formatInstant,
|
|
26
|
+
formatCountdown, localDate, isValidTimeZone, OVERLAP_POLICIES, MISSED_POLICIES,
|
|
27
|
+
} from '../shared/schedule/recurrence.mjs';
|
|
28
|
+
|
|
29
|
+
/** The value flags the run command gains (parseArgs registers them). */
|
|
30
|
+
export const SCHEDULE_VALUE_FLAGS = {
|
|
31
|
+
'--at': 'at', '--every': 'every', '--cron': 'cron', '--until': 'until', '--count': 'count',
|
|
32
|
+
'--overlap': 'overlap', '--max-failures': 'maxFailures', '--if-missed': 'ifMissed', '--grace': 'grace', '--tz': 'tz',
|
|
33
|
+
'--after': 'after',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const SCHEDULE_HELP = `worca schedule — manage scheduled runs
|
|
37
|
+
|
|
38
|
+
Create one by adding a time to any run command:
|
|
39
|
+
worca --prompt "<task>" --at "tomorrow 02:00" Run once, later
|
|
40
|
+
worca --prompt "<task>" --at 02:00 --wait …and hold this terminal until then
|
|
41
|
+
worca --prompt "<task>" --every "weekdays 02:00" Repeat
|
|
42
|
+
worca --prompt "<task>" --cron "0 2 * * 1-5" Repeat (cron subset)
|
|
43
|
+
worca --prompt "<task>" --after <id> --source-from-previous Start when another run ends, on its branch
|
|
44
|
+
|
|
45
|
+
--at <when> "02:00" (next), "today 22:00", "tomorrow 02:00", "+90m", "+2h",
|
|
46
|
+
"2026-09-19 02:00", or ISO 8601 with an offset. Local time unless an offset is given.
|
|
47
|
+
--every <pattern> "day 03:30", "3 days 02:00", "weekdays 02:00", "weekends 09:00",
|
|
48
|
+
"mon,thu 02:00", "2 weeks mon 02:00", "month 1 02:00", "month last 02:00"
|
|
49
|
+
--until <date> Last day a repeating schedule may run (YYYY-MM-DD)
|
|
50
|
+
--count <n> Stop a repeating schedule after n runs
|
|
51
|
+
--overlap <p> If the previous run is still going: skip (default) | start | queue
|
|
52
|
+
--max-failures <n> Pause a repeating schedule after n failures in a row (default 3, 0 = never)
|
|
53
|
+
--if-missed <p> If Worca is not running at that time: run (start late) | skip
|
|
54
|
+
--grace <dur> How late a missed run may still start: 90m, 6h, 1d (default 6h)
|
|
55
|
+
--tz <zone> IANA timezone for local times (default: this machine's)
|
|
56
|
+
--wait With --at: keep this terminal open and start the run here
|
|
57
|
+
--after <id> Start when another run ends: a run id or a scheduled run id (any unique prefix)
|
|
58
|
+
--after-any …even if that run fails or is stopped
|
|
59
|
+
--source-from-previous Start on that run's feature branch (with --after)
|
|
60
|
+
|
|
61
|
+
A scheduled run starts only while a Worca process is up (\`worca ui\`, or --wait) and the
|
|
62
|
+
machine is awake.
|
|
63
|
+
|
|
64
|
+
Commands:
|
|
65
|
+
worca schedule list [--all] Waiting runs and repeating schedules
|
|
66
|
+
worca schedule show <id> One item in detail
|
|
67
|
+
worca schedule run-now <id> Start it now (a repeating schedule gets one extra run)
|
|
68
|
+
worca schedule move <id> --at <when> Change the time of a one-off run
|
|
69
|
+
worca schedule move <id> --after <id> Wait for another run instead of a time
|
|
70
|
+
worca schedule cancel <id> Cancel a one-off run, or delete a repeating schedule
|
|
71
|
+
worca schedule skip <id> Skip the next run of a repeating schedule
|
|
72
|
+
worca schedule pause|resume <id> Pause or resume a repeating schedule
|
|
73
|
+
worca schedule log [--unread] [--mark-read] The activity feed
|
|
74
|
+
|
|
75
|
+
<id> may be any unique prefix.
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
const systemTz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
79
|
+
|
|
80
|
+
/** "6h" / "90m" / "1d" / bare minutes -> minutes, or null. */
|
|
81
|
+
export function parseGrace(text) {
|
|
82
|
+
const r = /^(\d+)\s*(m|min|h|hr|d)?$/i.exec(String(text || '').trim());
|
|
83
|
+
if (!r) return null;
|
|
84
|
+
const n = Number(r[1]); const u = (r[2] || 'm')[0].toLowerCase();
|
|
85
|
+
const min = n * (u === 'm' ? 1 : u === 'h' ? 60 : 1440);
|
|
86
|
+
return Number.isSafeInteger(min) && min >= 0 && min <= 10080 ? min : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** True when the flags ask for a schedule rather than an immediate run. */
|
|
90
|
+
export function wantsSchedule(flags) {
|
|
91
|
+
// `after !== undefined`, not truthiness: `--after ""` (an unset shell variable) must refuse, not start a run now.
|
|
92
|
+
return !!(flags.at || flags.every || flags.cron) || flags.after !== undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `--after <prefix>` -> the one ticket or pipeline whose id starts with it. */
|
|
96
|
+
export function resolveAfterId(prefix, fail) {
|
|
97
|
+
const q = String(prefix || '').trim();
|
|
98
|
+
if (!q) fail('--after needs a run id (see: worca schedule list, or the History view)');
|
|
99
|
+
// A series id (what `worca schedule list` prints under Repeating) is refused with the pinned sentence
|
|
100
|
+
// here; an OCCURRENCE's ticket id is found below (every scheduled_runs row, occurrences included) so
|
|
101
|
+
// resolveAfterRef can refuse it with the same sentence — hidden, it would read "no run matches".
|
|
102
|
+
if (q.startsWith('sch_')) fail('a repeating schedule is not supported — give the id of one of its runs');
|
|
103
|
+
// Both tables by prefix, straight from the DB: listTickets() is ORDER BY run_at (500 rows by default,
|
|
104
|
+
// 2000 at most), and an after-ticket sits at the 9999 sentinel — on a busy home it is the first row dropped.
|
|
105
|
+
const like = `${q.replace(/[\\%_]/g, '\\$&')}%`;
|
|
106
|
+
const tickets = getDb().prepare("SELECT id FROM scheduled_runs WHERE id LIKE ? ESCAPE '\\' ORDER BY created_at DESC LIMIT 21").all(like).map((t) => ({ kind: 'ticket', id: t.id }));
|
|
107
|
+
const seen = new Set(tickets.map((h) => h.id));
|
|
108
|
+
const hits = [
|
|
109
|
+
...tickets,
|
|
110
|
+
// A ticket's UUID is the RUN id of the run it becomes (ui/server.mjs: `runId = internal.ticket.id`);
|
|
111
|
+
// the pipeline row's id is a different, shorter 8-hex id (artifacts.mjs shortId). The two spaces
|
|
112
|
+
// never overlap today; the Set is a belt so one run can never be offered twice.
|
|
113
|
+
...getDb().prepare("SELECT id FROM pipelines WHERE id LIKE ? ESCAPE '\\' ORDER BY started_at DESC LIMIT 21")
|
|
114
|
+
.all(like).filter((p) => !seen.has(p.id)).map((p) => ({ kind: 'pipeline', id: p.id })),
|
|
115
|
+
];
|
|
116
|
+
if (!hits.length) fail(`no run or scheduled run matches "${q}"`);
|
|
117
|
+
if (hits.length > 1) fail(`"${q}" matches ${hits.length > 20 ? 'more than 20' : hits.length} runs — use a longer id`); // both queries are capped at 21: the count is exact below 21
|
|
118
|
+
return hits[0];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Validate the schedule flags. Returns { runAtMs | rule, overlap, maxFailures, ifMissed,
|
|
123
|
+
* graceMin, tz }; calls `fail` (which exits) on the first problem.
|
|
124
|
+
*/
|
|
125
|
+
export function readScheduleFlags(flags, { fail, now = Date.now(), projectDir = null }) {
|
|
126
|
+
const tz = flags.tz || systemTz();
|
|
127
|
+
if (!isValidTimeZone(tz)) fail(`--tz: "${tz}" is not a known timezone`);
|
|
128
|
+
const given = ['at', 'every', 'cron', 'after'].filter((k) => flags[k]);
|
|
129
|
+
if (given.length > 1) fail(`use one of --at, --every, --cron, --after (got: ${given.map((k) => `--${k}`).join(', ')})`);
|
|
130
|
+
if (flags.after !== undefined && !String(flags.after).trim()) fail('--after needs a run id (see: worca schedule list, or the History view)');
|
|
131
|
+
if (flags.sourceFromPrevious && !flags.after) fail('--source-from-previous needs --after');
|
|
132
|
+
if (flags.afterAny && !flags.after) fail('--after-any needs --after');
|
|
133
|
+
const defaults = scheduleDefaults();
|
|
134
|
+
if (flags.after) {
|
|
135
|
+
if (flags.wait) fail('--wait needs --at: a run after another run is started by the Worca server (worca ui)');
|
|
136
|
+
for (const [k, name] of [['ifMissed', '--if-missed'], ['grace', '--grace'], ['until', '--until'], ['count', '--count'], ['overlap', '--overlap'], ['maxFailures', '--max-failures']]) {
|
|
137
|
+
if (flags[k] !== undefined) fail(`${name} only applies to a timed schedule (--at / --every / --cron)`);
|
|
138
|
+
}
|
|
139
|
+
if (flags.sourceFromPrevious && flags.sourceBranch) fail('--source-from-previous and --source-branch cannot both be given');
|
|
140
|
+
const ref = resolveAfterId(flags.after, fail);
|
|
141
|
+
const policy = flags.afterAny ? 'any' : 'done';
|
|
142
|
+
const r = resolveAfterRef({ kind: ref.kind, id: ref.id }, { projectDir, policy });
|
|
143
|
+
if (!r.ok) fail(r.error);
|
|
144
|
+
// ifMissed / graceMin: the Settings defaults ride along, exactly as on a timed ticket — a chained
|
|
145
|
+
// ticket later moved to a time (`schedule move --at`) must not surface createTicket's hardcoded 360.
|
|
146
|
+
return { tz, after: r.after, afterPolicy: policy, sourceFromPrevious: !!flags.sourceFromPrevious, ifMissed: defaults.ifMissed, graceMin: defaults.graceMin };
|
|
147
|
+
}
|
|
148
|
+
const out = { tz, ifMissed: defaults.ifMissed, graceMin: defaults.graceMin, overlap: 'skip', maxFailures: defaults.maxFailures };
|
|
149
|
+
if (flags.ifMissed !== undefined) {
|
|
150
|
+
if (!MISSED_POLICIES.includes(flags.ifMissed)) fail(`--if-missed must be one of ${MISSED_POLICIES.join(', ')}, got: ${flags.ifMissed}`);
|
|
151
|
+
out.ifMissed = flags.ifMissed;
|
|
152
|
+
}
|
|
153
|
+
if (flags.grace !== undefined) {
|
|
154
|
+
const g = parseGrace(flags.grace);
|
|
155
|
+
if (g == null) fail(`--grace: cannot read "${flags.grace}" — try 90m, 6h or 1d (at most 7d)`);
|
|
156
|
+
out.graceMin = g;
|
|
157
|
+
}
|
|
158
|
+
if (flags.at) {
|
|
159
|
+
for (const k of ['until', 'count', 'overlap', 'maxFailures']) {
|
|
160
|
+
if (flags[k] !== undefined) fail(`--${k === 'maxFailures' ? 'max-failures' : k} only applies to a repeating schedule (--every / --cron)`);
|
|
161
|
+
}
|
|
162
|
+
const at = parseAt(flags.at, { nowMs: now, tz });
|
|
163
|
+
if (!at.ok) fail(at.error);
|
|
164
|
+
if (at.ms <= now) fail(`--at: ${formatInstant(at.ms, tz, { withYear: true })} is in the past`);
|
|
165
|
+
out.runAtMs = at.ms;
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
if (flags.wait) fail('--wait needs --at: a repeating schedule is started by the Worca server (worca ui)');
|
|
169
|
+
const parsed = flags.every ? parseEvery(flags.every) : parseCron(flags.cron);
|
|
170
|
+
if (!parsed.ok) fail(parsed.error);
|
|
171
|
+
const rule = { ...parsed.rule, tz };
|
|
172
|
+
if (flags.until !== undefined && flags.count !== undefined) fail('use --until or --count, not both');
|
|
173
|
+
if (flags.until !== undefined) rule.end = { type: 'until', until: String(flags.until) };
|
|
174
|
+
if (flags.count !== undefined) rule.end = { type: 'count', count: Number(flags.count) };
|
|
175
|
+
const norm = normalizeRule(rule, { todayLocal: localDate(now, tz) });
|
|
176
|
+
if (!norm.ok) fail(norm.error.replace(/^rule\.end\.until/, '--until').replace(/^rule\.end\.count/, '--count'));
|
|
177
|
+
if (nextOccurrence(norm.rule, now) == null) fail('this schedule has no future run');
|
|
178
|
+
out.rule = norm.rule;
|
|
179
|
+
if (flags.overlap !== undefined) {
|
|
180
|
+
if (!OVERLAP_POLICIES.includes(flags.overlap)) fail(`--overlap must be one of ${OVERLAP_POLICIES.join(', ')}, got: ${flags.overlap}`);
|
|
181
|
+
out.overlap = flags.overlap;
|
|
182
|
+
}
|
|
183
|
+
if (flags.maxFailures !== undefined) {
|
|
184
|
+
const n = Number(flags.maxFailures);
|
|
185
|
+
if (!Number.isSafeInteger(n) || n < 0 || n > 100) fail(`--max-failures must be a whole number from 0 to 100, got: ${flags.maxFailures}`);
|
|
186
|
+
out.maxFailures = n;
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Copy `--extras` files into the durable staging dir; returns the staged paths. */
|
|
192
|
+
async function stageExtras(stageId, extras) {
|
|
193
|
+
if (!extras.length) return [];
|
|
194
|
+
const dir = join(scheduleStageDir(stageId), 'extras');
|
|
195
|
+
await mkdir(dir, { recursive: true });
|
|
196
|
+
const out = [];
|
|
197
|
+
for (const src of extras) {
|
|
198
|
+
const dest = join(dir, basename(src));
|
|
199
|
+
await copyFile(src, dest);
|
|
200
|
+
out.push(dest);
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The stored request (POST /api/run body shape + `internal`) for the CLI's flags. */
|
|
206
|
+
async function requestFromFlags(flags, { projectDir, extras, promptText, stageId }) {
|
|
207
|
+
const request = {
|
|
208
|
+
projectDir,
|
|
209
|
+
workflowId: flags.workflow || 'wf_default',
|
|
210
|
+
...(flags.title ? { title: flags.title } : {}),
|
|
211
|
+
...(flags.mock ? { mock: true } : {}),
|
|
212
|
+
...(flags.sourceBranch ? { sourceBranch: flags.sourceBranch } : {}),
|
|
213
|
+
...(flags.featureBranch ? { featureBranch: flags.featureBranch } : {}),
|
|
214
|
+
...(flags.memoryScope ? { memoryScope: flags.memoryScope } : {}),
|
|
215
|
+
...(flags.humanInLoop === false ? { humanInLoop: false } : {}),
|
|
216
|
+
};
|
|
217
|
+
// Text the user authored is frozen on the ticket — including a --file's content.
|
|
218
|
+
if (flags.file) request.promptMarkdown = promptText;
|
|
219
|
+
else request.prompt = flags.prompt;
|
|
220
|
+
request.internal = {
|
|
221
|
+
extrasPaths: await stageExtras(stageId, extras),
|
|
222
|
+
...(flags.model ? { model: flags.model } : {}),
|
|
223
|
+
...(flags.permissionMode ? { permissionMode: flags.permissionMode } : {}),
|
|
224
|
+
...(flags.auto ? { auto: true } : {}),
|
|
225
|
+
};
|
|
226
|
+
return request;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function fallbackTitle(flags, promptText) {
|
|
230
|
+
if (flags.title) return flags.title;
|
|
231
|
+
const line = String(promptText || flags.prompt || '').split('\n').map((l) => l.replace(/^#+\s*/, '').trim()).find(Boolean) || 'Scheduled run';
|
|
232
|
+
return line.length > 80 ? `${line.slice(0, 80)}…` : line;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function serverIsUp() {
|
|
236
|
+
const inst = readUiInstance();
|
|
237
|
+
if (!inst) return false;
|
|
238
|
+
try { return (await probeUi({ host: inst.host, port: inst.port })).state === 'worca'; } catch { return false; }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* `worca … --at/--every/--cron`: write the ticket or the schedule. Returns
|
|
243
|
+
* { ticket, schedule, spec } — the caller decides whether to --wait.
|
|
244
|
+
*/
|
|
245
|
+
export async function createFromFlags(flags, { projectDir, extras, promptText, spec, out, c }) {
|
|
246
|
+
const title = fallbackTitle(flags, promptText);
|
|
247
|
+
let ticket, schedule = null;
|
|
248
|
+
if (spec.rule) {
|
|
249
|
+
const id = `sch_${randomBytes(4).toString('hex')}`;
|
|
250
|
+
const request = await requestFromFlags(flags, { projectDir, extras, promptText, stageId: id });
|
|
251
|
+
({ schedule, ticket } = createSchedule({
|
|
252
|
+
id, title, projectDir, request, rule: spec.rule, overlap: spec.overlap, maxFailures: spec.maxFailures,
|
|
253
|
+
ifMissed: spec.ifMissed, graceMin: spec.graceMin,
|
|
254
|
+
}));
|
|
255
|
+
out(`${c('green', 'Scheduled')} ${c('bold', schedule.id)} — ${describeRule(schedule.rule)} (${spec.tz})`);
|
|
256
|
+
if (ticket) out(` Next run: ${formatInstant(Date.parse(ticket.runAt), spec.tz, { withYear: true })} (in ${formatCountdown(Date.parse(ticket.runAt) - Date.now())})`);
|
|
257
|
+
} else {
|
|
258
|
+
const id = randomUUID();
|
|
259
|
+
const request = await requestFromFlags(flags, { projectDir, extras, promptText, stageId: id });
|
|
260
|
+
if (spec.after) {
|
|
261
|
+
ticket = createTicket({ id, title, projectDir, request, after: { kind: spec.after.kind, id: spec.after.id }, afterPolicy: spec.afterPolicy, sourceFromPrevious: spec.sourceFromPrevious, ifMissed: spec.ifMissed, graceMin: spec.graceMin });
|
|
262
|
+
out(`${c('green', 'Scheduled')} ${c('bold', ticket.id.slice(0, 8))} after ‘${spec.after.title || spec.after.id.slice(0, 8)}’ (${spec.after.status})`);
|
|
263
|
+
} else {
|
|
264
|
+
ticket = createTicket({
|
|
265
|
+
id, title, projectDir, runAtMs: spec.runAtMs, request, ifMissed: spec.ifMissed, graceMin: spec.graceMin,
|
|
266
|
+
...(flags.wait ? { ownerPid: process.pid, ownerHost: hostname() } : {}),
|
|
267
|
+
});
|
|
268
|
+
out(`${c('green', 'Scheduled')} ${c('bold', ticket.id.slice(0, 8))} for ${formatInstant(spec.runAtMs, spec.tz, { withYear: true })} (in ${formatCountdown(spec.runAtMs - Date.now())})`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
out(` ${title}`);
|
|
272
|
+
if (!flags.wait && !(await serverIsUp())) {
|
|
273
|
+
out(c('yellow', 'Note: no Worca server is up. Start `worca ui` before then'
|
|
274
|
+
+ (spec.rule || spec.after ? '.' : ', or add --wait to hold this terminal.')));
|
|
275
|
+
}
|
|
276
|
+
out(c('gray', ` Manage it: worca schedule list | show | run-now | cancel ${(schedule ? schedule.id : ticket.id.slice(0, 8))}`));
|
|
277
|
+
return { ticket, schedule };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* `--wait`: this terminal owns the ticket. Poll the row (so Run now / Change time /
|
|
282
|
+
* Cancel from the UI still work), then start the run HERE through `drive`.
|
|
283
|
+
* @param {object} o
|
|
284
|
+
* @param {(onPipelineId:(id:string)=>void) => Promise<{code:number, status:string}>} o.drive
|
|
285
|
+
* @returns {Promise<number>} exit code
|
|
286
|
+
*/
|
|
287
|
+
export async function waitAndRun({ ticketId, tz, out, c, drive, pollMs = 5000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
|
|
288
|
+
let released = false;
|
|
289
|
+
const onSigint = () => {
|
|
290
|
+
if (released) return;
|
|
291
|
+
released = true;
|
|
292
|
+
try { releaseTicket(ticketId); } catch { /* best effort */ }
|
|
293
|
+
process.stdout.write('\n');
|
|
294
|
+
out(c('yellow', 'Stopped waiting. The run stays scheduled — the Worca server will start it (worca ui).'));
|
|
295
|
+
out(c('gray', ` To cancel it instead: worca schedule cancel ${ticketId.slice(0, 8)}`));
|
|
296
|
+
process.exit(0);
|
|
297
|
+
};
|
|
298
|
+
process.on('SIGINT', onSigint);
|
|
299
|
+
out(c('gray', 'Waiting here. Ctrl+C hands the run to the Worca server; it stays scheduled.'));
|
|
300
|
+
let lastLine = '';
|
|
301
|
+
try {
|
|
302
|
+
for (;;) {
|
|
303
|
+
const t = getTicket(ticketId);
|
|
304
|
+
if (!t) { out(c('yellow', 'The scheduled run was removed.')); return 0; }
|
|
305
|
+
if (t.status === 'canceled') { out(c('yellow', 'The scheduled run was canceled.')); return 0; }
|
|
306
|
+
if (t.status !== 'scheduled') { out(c('yellow', `The scheduled run is now ${t.status} — another Worca process took it.`)); return 0; }
|
|
307
|
+
if (t.ownerPid !== process.pid) { out(c('yellow', 'This terminal no longer owns the run — the Worca server will start it.')); return 0; }
|
|
308
|
+
const now = Date.now();
|
|
309
|
+
const due = t.forced || Date.parse(t.runAt) <= now;
|
|
310
|
+
if (due) break;
|
|
311
|
+
heartbeatTicket(ticketId);
|
|
312
|
+
const line = ` starts ${formatInstant(Date.parse(t.runAt), tz)} — in ${formatCountdown(Date.parse(t.runAt) - now)}`;
|
|
313
|
+
if (line !== lastLine) {
|
|
314
|
+
if (process.stdout.isTTY) process.stdout.write(`\r\x1b[2K${line}`); else out(line);
|
|
315
|
+
lastLine = line;
|
|
316
|
+
}
|
|
317
|
+
const left = Date.parse(t.runAt) - now;
|
|
318
|
+
await sleep(Math.max(250, Math.min(pollMs, left)));
|
|
319
|
+
}
|
|
320
|
+
if (process.stdout.isTTY) process.stdout.write('\r\x1b[2K');
|
|
321
|
+
if (!claimTicket(ticketId)) { out(c('yellow', 'Another Worca process started this run.')); return 0; }
|
|
322
|
+
} finally {
|
|
323
|
+
process.removeListener('SIGINT', onSigint);
|
|
324
|
+
}
|
|
325
|
+
released = true; // from here the run's own Ctrl+C ladder (pause/stop) is in charge
|
|
326
|
+
const t = getTicket(ticketId);
|
|
327
|
+
const late = Date.now() - Date.parse(t.runAt);
|
|
328
|
+
markTicketFired(ticketId);
|
|
329
|
+
if (!t.forced && late > 5 * 60_000) {
|
|
330
|
+
addNotification({ kind: 'late', severity: 'info', ticketId, projectDir: t.projectDir, title: t.title, message: `started late. It was due at ${t.runAt}.` });
|
|
331
|
+
}
|
|
332
|
+
out(c('cyan', `Starting the scheduled run — ${t.title || ticketId.slice(0, 8)}`));
|
|
333
|
+
let res;
|
|
334
|
+
try {
|
|
335
|
+
res = await drive((pipelineId) => { try { setTicketPipeline(ticketId, pipelineId); } catch { /* decoration */ } });
|
|
336
|
+
} catch (err) {
|
|
337
|
+
recordOutcome(ticketId, { status: 'error', detail: err && err.message ? err.message : String(err) });
|
|
338
|
+
throw err;
|
|
339
|
+
}
|
|
340
|
+
recordOutcome(ticketId, { status: res.status, pipelineId: res.pipelineId || null, reason: res.reason || null, detail: res.detail || null });
|
|
341
|
+
return res.code;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── worca schedule <verb> ────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
function resolveItem(ref, fail) {
|
|
347
|
+
const q = String(ref || '').trim();
|
|
348
|
+
if (!q) fail('an id is required (see: worca schedule list)');
|
|
349
|
+
const hits = [
|
|
350
|
+
...listSchedules().filter((s) => s.id.startsWith(q) || s.id.slice(4).startsWith(q)).map((s) => ({ kind: 'recurring', item: s })),
|
|
351
|
+
// Tickets by prefix straight from the DB, the resolveAfterId idiom: listTickets() is ORDER BY run_at
|
|
352
|
+
// (2000 rows at most) and an after-ticket sits at the 9999 sentinel — on a busy home it is the first
|
|
353
|
+
// row dropped, and `show` / `move` / `cancel` would answer "no scheduled run … matches" for a ticket
|
|
354
|
+
// that `--after` still finds. LIKE metacharacters in the prefix are literal.
|
|
355
|
+
...getDb().prepare("SELECT id FROM scheduled_runs WHERE id LIKE ? ESCAPE '\\' ORDER BY created_at DESC LIMIT 21")
|
|
356
|
+
.all(`${q.replace(/[\\%_]/g, '\\$&')}%`).map((r) => ({ kind: 'once', item: getTicket(r.id) })),
|
|
357
|
+
];
|
|
358
|
+
if (!hits.length) fail(`no scheduled run or schedule matches "${q}" (see: worca schedule list)`);
|
|
359
|
+
if (hits.length > 1) fail(`"${q}" matches ${hits.length} items — use a longer id`);
|
|
360
|
+
return hits[0];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const where = (x) => (x.workspaceId ? `workspace ${x.workspaceId}` : (x.projectDir ? basename(x.projectDir) : '—'));
|
|
364
|
+
|
|
365
|
+
// `list` drops the status: a regex strip of ` (…)` would also eat a title's own parentheses.
|
|
366
|
+
const afterLabel = (t, { withStatus = true } = {}) => { const r = afterRefOf(t.after.id); return `‘${(r && r.title) || t.after.id.slice(0, 8)}’${withStatus && r ? ` (${r.status})` : ''}`; };
|
|
367
|
+
|
|
368
|
+
export async function cmdSchedule(argv, { out, c, fail }) {
|
|
369
|
+
const verb = argv[0];
|
|
370
|
+
const rest = argv.slice(1);
|
|
371
|
+
const tz = systemTz();
|
|
372
|
+
const when = (isoStr) => (isoStr ? formatInstant(Date.parse(isoStr), tz, { withYear: true }) : '—');
|
|
373
|
+
if (!verb || verb === 'help' || verb === '--help' || verb === '-h') { process.stdout.write(SCHEDULE_HELP); return 0; }
|
|
374
|
+
|
|
375
|
+
if (verb === 'list') {
|
|
376
|
+
const all = rest.includes('--all');
|
|
377
|
+
const series = listSchedules({ includeEnded: all });
|
|
378
|
+
const tickets = listTickets({ all, oneShotOnly: true });
|
|
379
|
+
if (!series.length && !tickets.length) { out('No scheduled runs. Add --at or --every to a run command (see: worca schedule help).'); return 0; }
|
|
380
|
+
if (series.length) {
|
|
381
|
+
out(c('bold', 'Repeating'));
|
|
382
|
+
for (const s of series) {
|
|
383
|
+
const state = s.status === 'active' ? `next ${when(s.nextRunAt)}` : s.status === 'paused' ? c('yellow', s.pauseReason === 'failure_streak' ? `paused after ${s.failureStreak} failures` : 'paused') : 'ended';
|
|
384
|
+
out(` ${s.id} ${s.sentence} · ${state} · ${where(s)} · ${s.title || ''}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (tickets.length) {
|
|
388
|
+
out(c('bold', 'Once'));
|
|
389
|
+
for (const t of tickets) {
|
|
390
|
+
const st = t.after && t.status === 'scheduled' ? 'waiting' : t.status === 'scheduled' ? `in ${formatCountdown(Date.parse(t.runAt) - Date.now()) || 'a moment'}` : t.status === 'missed' ? c('yellow', 'missed') : t.status;
|
|
391
|
+
const held = t.ownerPid != null && t.status === 'scheduled' ? ' · held by a waiting terminal' : '';
|
|
392
|
+
out(` ${t.id.slice(0, 8)} ${t.after ? `after ${afterLabel(t, { withStatus: false })}` : when(t.runAt)} · ${st} · ${where(t)} · ${t.title || ''}${held}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const unread = unreadCount('schedule');
|
|
396
|
+
if (unread) out(c('yellow', `\n${unread} unread problem${unread === 1 ? '' : 's'} — worca schedule log --unread`));
|
|
397
|
+
return 0;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (verb === 'log') {
|
|
401
|
+
const rows = listNotifications({ unread: rest.includes('--unread'), limit: 50 }).reverse();
|
|
402
|
+
if (!rows.length) { out('Nothing to report.'); return 0; }
|
|
403
|
+
for (const n of rows) {
|
|
404
|
+
const mark = n.unread ? c('yellow', '●') : ' ';
|
|
405
|
+
out(`${mark} ${when(n.createdAt)} ${(n.kind || '').padEnd(10)} ${n.title || 'Scheduled run'} ${n.message}`);
|
|
406
|
+
}
|
|
407
|
+
if (rest.includes('--mark-read')) { markAllRead('schedule'); out(c('gray', 'Marked all as read.')); }
|
|
408
|
+
return 0;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const known = new Set(['show', 'run-now', 'move', 'cancel', 'skip', 'pause', 'resume']);
|
|
412
|
+
if (!known.has(verb)) fail(`unknown schedule command "${verb}" — see: worca schedule help`);
|
|
413
|
+
// The item is the first bare argument that is not a value flag's VALUE: `move --after <id> <ticket>`
|
|
414
|
+
// must move <ticket>, not resolve <id> as the item and then refuse it as "this run".
|
|
415
|
+
const VALUE_FLAGS = new Set(['--at', '--after']);
|
|
416
|
+
const found = resolveItem(rest.find((a, i) => !a.startsWith('-') && !(i > 0 && VALUE_FLAGS.has(rest[i - 1]))), fail);
|
|
417
|
+
const { kind, item } = found;
|
|
418
|
+
const label = kind === 'recurring' ? item.id : item.id.slice(0, 8);
|
|
419
|
+
|
|
420
|
+
if (verb === 'show') {
|
|
421
|
+
out(c('bold', item.title || label));
|
|
422
|
+
out(` id ${item.id}`);
|
|
423
|
+
out(` target ${where(item)}`);
|
|
424
|
+
if (kind === 'recurring') {
|
|
425
|
+
out(` repeats ${item.sentence} (${item.tz})`);
|
|
426
|
+
out(` status ${item.status}${item.pauseReason ? ` (${item.pauseReason.replace(/_/g, ' ')})` : ''}`);
|
|
427
|
+
out(` next run ${when(item.nextRunAt)}`);
|
|
428
|
+
out(` runs ${item.runsCount}${item.lastResult ? `, last: ${item.lastResult}` : ''}`);
|
|
429
|
+
out(` overlap ${item.overlap} pause after ${item.maxFailures || 'no'} failures (streak ${item.failureStreak})`);
|
|
430
|
+
} else {
|
|
431
|
+
if (item.after) out(` after ${afterLabel(item)}`);
|
|
432
|
+
else out(` when ${when(item.runAt)}`);
|
|
433
|
+
out(` status ${item.status}${item.failReason ? ` — ${item.failReason}` : ''}`);
|
|
434
|
+
if (item.after) out(` on error ${item.after.policy === 'any' ? 'start anyway' : 'do not start'}`);
|
|
435
|
+
if (item.sourceFromPrevious) out(` source the run before it`);
|
|
436
|
+
if (item.pipelineId) out(` pipeline ${item.pipelineId}`);
|
|
437
|
+
}
|
|
438
|
+
if (!(kind === 'once' && item.after)) out(` if missed ${item.ifMissed === 'skip' ? 'skip' : `start late, within ${item.graceMin} min`}`);
|
|
439
|
+
out(` workflow ${item.summary.workflowId}${item.summary.mock ? ' (mock)' : ''}`);
|
|
440
|
+
if (item.summary.prompt) out(` task ${item.summary.prompt.split('\n')[0].slice(0, 100)}`);
|
|
441
|
+
return 0;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (verb === 'run-now') {
|
|
445
|
+
if (kind === 'once' && item.after && item.sourceFromPrevious) {
|
|
446
|
+
const p = predecessorState(item.after, { policy: item.after.policy });
|
|
447
|
+
if (!p.pipelineId || !previousBranchesOf(p.pipelineId)) fail(`Start ‘${p.title || 'the run before it'}’ first, or change its source branch`);
|
|
448
|
+
}
|
|
449
|
+
const t = kind === 'recurring' ? runScheduleNow(item.id) : requestRunNow(item.id);
|
|
450
|
+
if (!t) fail(`${label} is ${item.status} and cannot be started`);
|
|
451
|
+
out(`Asked ${c('bold', label)} to start now.`);
|
|
452
|
+
if (!(await serverIsUp()) && t.ownerPid == null) out(c('yellow', 'Note: no Worca server is up — it starts as soon as `worca ui` runs.'));
|
|
453
|
+
return 0;
|
|
454
|
+
}
|
|
455
|
+
if (verb === 'cancel') {
|
|
456
|
+
if (kind === 'recurring') { deleteSchedule(item.id); out(`Deleted the repeating schedule ${c('bold', label)}.`); return 0; }
|
|
457
|
+
if (item.scheduleId) fail(`${label} is the next run of ${item.scheduleId} — use: worca schedule skip ${item.scheduleId}`);
|
|
458
|
+
if (!cancelTicket(item.id)) fail(`${label} is ${item.status} and can no longer be canceled`);
|
|
459
|
+
out(`Canceled ${c('bold', label)}.`);
|
|
460
|
+
return 0;
|
|
461
|
+
}
|
|
462
|
+
if (verb === 'move') {
|
|
463
|
+
if (kind === 'recurring') fail('a repeating schedule has no single time — delete it and create a new one, or edit it in the UI');
|
|
464
|
+
if (item.scheduleId) fail(`${label} is the next run of ${item.scheduleId} and cannot be moved — skip it instead`);
|
|
465
|
+
const i = rest.indexOf('--at');
|
|
466
|
+
const inline = rest.find((a) => a.startsWith('--at='));
|
|
467
|
+
const value = inline ? inline.slice(5) : (i !== -1 ? rest[i + 1] : undefined);
|
|
468
|
+
const ai = rest.indexOf('--after');
|
|
469
|
+
const aInline = rest.find((a) => a.startsWith('--after='));
|
|
470
|
+
const afterVal = aInline ? aInline.slice(8) : (ai !== -1 ? rest[ai + 1] : undefined);
|
|
471
|
+
if (afterVal && value) fail('use --at or --after, not both');
|
|
472
|
+
if (afterVal) {
|
|
473
|
+
const ref = resolveAfterId(afterVal, fail);
|
|
474
|
+
const policy = rest.includes('--after-any') ? 'any' : (item.after ? item.after.policy : 'done');
|
|
475
|
+
const r = resolveAfterRef({ kind: ref.kind, id: ref.id }, { projectDir: item.projectDir, workspaceId: item.workspaceId, policy, selfId: item.id });
|
|
476
|
+
if (!r.ok) fail(r.error);
|
|
477
|
+
if (!updateTicket(item.id, { after: { kind: r.after.kind, id: r.after.id }, afterPolicy: policy, ...(rest.includes('--source-from-previous') ? { sourceFromPrevious: true } : {}) })) fail(`${label} is ${item.status} and can no longer be moved`);
|
|
478
|
+
out(`Moved ${c('bold', label)} after ‘${r.after.title || r.after.id.slice(0, 8)}’.`);
|
|
479
|
+
return 0;
|
|
480
|
+
}
|
|
481
|
+
if (!value) fail('move needs --at "<when>" or --after <id>');
|
|
482
|
+
const at = parseAt(value, { nowMs: Date.now(), tz });
|
|
483
|
+
if (!at.ok) fail(at.error);
|
|
484
|
+
if (at.ms <= Date.now()) fail('--at: that time is in the past');
|
|
485
|
+
if (!updateTicket(item.id, { runAtMs: at.ms })) fail(`${label} is ${item.status} and can no longer be moved`);
|
|
486
|
+
out(`Moved ${c('bold', label)} to ${formatInstant(at.ms, tz, { withYear: true })} (in ${formatCountdown(at.ms - Date.now())}).`);
|
|
487
|
+
return 0;
|
|
488
|
+
}
|
|
489
|
+
if (kind !== 'recurring') fail(`${verb} applies to a repeating schedule; ${label} runs once`);
|
|
490
|
+
const s = verb === 'skip' ? skipNext(item.id) : verb === 'pause' ? pauseSchedule(item.id) : resumeSchedule(item.id);
|
|
491
|
+
if (!s) fail(`${label} is ${item.status}`);
|
|
492
|
+
out(verb === 'skip' ? `Skipped. Next run: ${when(s.nextRunAt)}.` : verb === 'pause' ? `Paused ${c('bold', label)}.` : `Resumed ${c('bold', label)}. Next run: ${when(s.nextRunAt)}.`);
|
|
493
|
+
return 0;
|
|
494
|
+
}
|