@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
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// src/shared/graph/validate.mjs
|
|
2
|
-
// THE authority on v2 graph legality (base spec §2, V1-
|
|
3
|
-
//
|
|
4
|
-
// references remain stable). Server save (422), plugin import, the composer's
|
|
2
|
+
// THE authority on v2 graph legality (base spec §2, V1-V22; V22 (script config)
|
|
3
|
+
// reuses the number the retired single-wire rule freed). Server save (422), plugin import, the composer's
|
|
5
4
|
// live report, the run-time check and the seed drift guard all come through
|
|
6
5
|
// here, so it is pure, never throws on a malformed template, and every lookup
|
|
7
6
|
// guards: a dangling endpoint or an unknown key is an issue to COLLECT.
|
|
8
|
-
import { KINDS, NODE_ID_RE, LIMITS } from './constants.mjs';
|
|
7
|
+
import { KINDS, KEYED_KINDS, NODE_ID_RE, LIMITS, PARAMS_PORT } from './constants.mjs';
|
|
9
8
|
import { portsOf, findPort, resolveOrOutType } from './ports.mjs';
|
|
10
9
|
import { classifyLoops } from './loops.mjs';
|
|
10
|
+
import { paramValueError, mockErrors, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS, pythonMissingSentence, wirableParams, WIRABLE_PARAM_TYPES } from './script-meta.mjs';
|
|
11
11
|
|
|
12
12
|
const ARITY_SET = new Set(['and', 'or', 'combine']);
|
|
13
13
|
const IN_PORT_RE = /^in\d+$/;
|
|
@@ -19,6 +19,7 @@ const AWAIT_PORT_ID = 'await';
|
|
|
19
19
|
* on a flow card. Unknown keys are PRESERVED and ignored, never stripped. */
|
|
20
20
|
const KNOWN_CONFIG = {
|
|
21
21
|
agent: new Set(['model', 'effort', 'fanOut', 'askQuestions', 'awaitAll', 'subagentModel']),
|
|
22
|
+
script: new Set(['params', 'ports', 'timeoutMs', 'awaitAll', 'mock', 'paramsPort']),
|
|
22
23
|
task: new Set(['planStoreSeed']),
|
|
23
24
|
and: new Set(['arity']),
|
|
24
25
|
or: new Set(['arity']),
|
|
@@ -32,6 +33,8 @@ const KNOWN_WIRE_CONFIG = new Set(['maxCycles']);
|
|
|
32
33
|
* (workflows.mjs#setWorkflowNodeDefaults, ui/server.mjs template save). A
|
|
33
34
|
* strict subset of KNOWN_CONFIG.agent: awaitAll is TOPOLOGY, not a tunable. */
|
|
34
35
|
export const AGENT_TUNABLES = ['model', 'effort', 'fanOut', 'askQuestions', 'subagentModel'];
|
|
36
|
+
/** The per-node SCRIPT tunables (P2's per-project overlays); unused by v1 writers. */
|
|
37
|
+
export const SCRIPT_TUNABLES = ['params', 'timeoutMs'];
|
|
35
38
|
|
|
36
39
|
const isObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
37
40
|
|
|
@@ -184,8 +187,8 @@ export const RULES = [
|
|
|
184
187
|
for (const n of nodes) {
|
|
185
188
|
if (!KINDS.includes(n.kind)) { // KINDS is a frozen ARRAY (P1 constants)
|
|
186
189
|
add(`node '${n.id}' has unknown kind ${JSON.stringify(n.kind)} — expected one of ${[...KINDS].join(', ')}`, { nodeId: n.id });
|
|
187
|
-
} else if (n.kind
|
|
188
|
-
else if (n.kind
|
|
190
|
+
} else if (KEYED_KINDS.includes(n.kind) && !n.key) add(`${n.kind} node '${n.id}' must declare a key`, { nodeId: n.id });
|
|
191
|
+
else if (!KEYED_KINDS.includes(n.kind) && n.key !== undefined) {
|
|
189
192
|
add(`node '${n.id}' of kind '${n.kind}' must not declare a key`, { nodeId: n.id });
|
|
190
193
|
}
|
|
191
194
|
}
|
|
@@ -193,12 +196,25 @@ export const RULES = [
|
|
|
193
196
|
|
|
194
197
|
{ code: 'V4', level: 'E', check({ nodes, portsFor }, add) {
|
|
195
198
|
for (const n of nodes) {
|
|
196
|
-
if (n.kind
|
|
199
|
+
if (!KEYED_KINDS.includes(n.kind) || !n.key) continue; // a missing key is V3's
|
|
197
200
|
const p = portsFor(n.id);
|
|
198
|
-
|
|
199
|
-
|
|
201
|
+
const what = n.kind;
|
|
202
|
+
if (!p.known) add(`unknown ${what} "${n.key}" — no such key in the registry`, { nodeId: n.id });
|
|
203
|
+
else if (!p.ported && p.meta?.configPortsMissing) {
|
|
204
|
+
add(`script "${n.key}" takes its ports per card — add them in the inspector`, { nodeId: n.id, incomplete: true });
|
|
205
|
+
} else if (!p.ported && p.meta?.configPortsInvalid) {
|
|
206
|
+
add(`script "${n.key}" has invalid ports in its config — fix them in the inspector`, { nodeId: n.id });
|
|
207
|
+
} else if (!p.ported) add(`agent "${n.key}" has no v2 ports — port its sidecar to metaVersion 2`, { nodeId: n.id });
|
|
200
208
|
else if (p.meta?.placeable === false) {
|
|
201
|
-
add(
|
|
209
|
+
add(`${what} "${n.key}" declares placeable: false and cannot be a graph node`, { nodeId: n.id });
|
|
210
|
+
} else if (p.meta?.runtime === 'python' && p.meta?.runtimeMissing) {
|
|
211
|
+
// Workbench §7 / W17: GET /api/scripts stamps `runtimeMissing` on a python
|
|
212
|
+
// meta when THIS host's probe failed, so the composer paints the card red
|
|
213
|
+
// the moment it is placed. The registry itself never carries the flag (the
|
|
214
|
+
// loader is synchronous, the probe is not), so a saved graph and a run are
|
|
215
|
+
// gated by the run preflight, which says the same sentence at the right
|
|
216
|
+
// moment. Listed always, unplaceable with a reason.
|
|
217
|
+
add(pythonMissingSentence(n.key), { nodeId: n.id });
|
|
202
218
|
}
|
|
203
219
|
}
|
|
204
220
|
} },
|
|
@@ -263,10 +279,10 @@ export const RULES = [
|
|
|
263
279
|
|
|
264
280
|
{ code: 'V9', level: 'E', check({ nodes, metaOf, inputsOf, isWired, isLoopInput }, add) {
|
|
265
281
|
for (const n of nodes) {
|
|
266
|
-
//
|
|
282
|
+
// KEYED inputs only. P1 marks the gate `inK` ports and End's `result`
|
|
267
283
|
// `required: true`, and V12/V21 already own them with a card-specific
|
|
268
284
|
// sentence — without this guard one unwired `n_and.in2` reports TWICE.
|
|
269
|
-
if (n.kind
|
|
285
|
+
if (!KEYED_KINDS.includes(n.kind) || !metaOf(n.id)) continue; // V3/V4 own an unresolved node
|
|
270
286
|
for (const inp of inputsOf(n.id)) {
|
|
271
287
|
if (!inp?.required || inp.loop || isLoopInput(n.id, inp.id)) continue;
|
|
272
288
|
// `incomplete`: the graph is UNFINISHED here, not wrong — the composer shows
|
|
@@ -403,7 +419,7 @@ export const RULES = [
|
|
|
403
419
|
|
|
404
420
|
{ code: 'V16', level: 'W', check({ nodes, metaOf, inputsOf, isWired, isLoopInput }, add) {
|
|
405
421
|
for (const n of nodes) {
|
|
406
|
-
if (n.kind
|
|
422
|
+
if (!KEYED_KINDS.includes(n.kind) || !n.config?.awaitAll || !metaOf(n.id)) continue;
|
|
407
423
|
const wired = inputsOf(n.id).filter((inp) => !isLoopInput(n.id, inp.id) && isWired(n.id, inp.id));
|
|
408
424
|
if (wired.length < 2) {
|
|
409
425
|
add(`node '${n.id}' sets awaitAll but has ${wired.length} wired non-loop input(s) — the barrier is a no-op`,
|
|
@@ -434,7 +450,7 @@ export const RULES = [
|
|
|
434
450
|
// always source, so without it every double-loop seed would warn permanently.
|
|
435
451
|
{ code: 'V18', level: 'W', check({ nodes, nodeById, metaOf, inputsOf, inboundOf, outputPort, isLoopInput }, add) {
|
|
436
452
|
for (const n of nodes) {
|
|
437
|
-
if (n.kind
|
|
453
|
+
if (!KEYED_KINDS.includes(n.kind) || n.config?.awaitAll || !metaOf(n.id)) continue;
|
|
438
454
|
let paired = 0;
|
|
439
455
|
for (const inp of inputsOf(n.id)) {
|
|
440
456
|
if (inp.id === AWAIT_PORT_ID || inp.synthetic) continue; // (c)
|
|
@@ -447,7 +463,7 @@ export const RULES = [
|
|
|
447
463
|
paired += 1;
|
|
448
464
|
}
|
|
449
465
|
if (paired >= 2) {
|
|
450
|
-
add(
|
|
466
|
+
add(`${n.kind} node '${n.id}' has ${paired} always-sourced payload inputs without awaitAll — `
|
|
451
467
|
+ 'it may double-fire on re-runs (enable Await-all or insert an AND card)', { nodeId: n.id });
|
|
452
468
|
}
|
|
453
469
|
}
|
|
@@ -463,7 +479,7 @@ export const RULES = [
|
|
|
463
479
|
const target = nodeById.get(w.to.node);
|
|
464
480
|
if ((target.kind === 'and' || target.kind === 'or') && IN_PORT_RE.test(w.to.port)) continue;
|
|
465
481
|
if (target.kind === 'end' && w.to.port === 'result') continue;
|
|
466
|
-
if (target.kind
|
|
482
|
+
if (KEYED_KINDS.includes(target.kind) && w.to.port === AWAIT_PORT_ID) continue;
|
|
467
483
|
add(`blocking output '${w.from.node}.${w.from.port}' is wired into '${w.to.node}.${w.to.port}', `
|
|
468
484
|
+ 'which is not a loop input', { wireId: w.id });
|
|
469
485
|
}
|
|
@@ -490,4 +506,61 @@ export const RULES = [
|
|
|
490
506
|
if (!isWired(e.id, 'result')) add(`end node '${e.id}' input 'result' must be wired`, { nodeId: e.id, incomplete: true });
|
|
491
507
|
}
|
|
492
508
|
} },
|
|
509
|
+
|
|
510
|
+
// Script config (spec §9.1 V22): params against the sidecar's declarations,
|
|
511
|
+
// `ports` only where the sidecar says "config" (a MISSING or unreadable config
|
|
512
|
+
// is V4's), `mock` against the declared non-void outputs, `timeoutMs` an integer
|
|
513
|
+
// within [1 s, 24 h]. `incomplete` marks work-to-do (a required param nobody set yet).
|
|
514
|
+
// `paramsPort` only where it takes effect; a WIRED params port defers a required wirable param to the runner.
|
|
515
|
+
{ code: 'V22', level: 'E', check({ nodes, portsFor, isWired }, add) {
|
|
516
|
+
for (const n of nodes) {
|
|
517
|
+
if (n.kind !== 'script' || !n.key) continue;
|
|
518
|
+
const p = portsFor(n.id);
|
|
519
|
+
if (!p.known) continue; // V4 owns it
|
|
520
|
+
const meta = p.meta || {};
|
|
521
|
+
const cfg = isObject(n.config) ? n.config : {};
|
|
522
|
+
const declared = Array.isArray(meta.params) ? meta.params : [];
|
|
523
|
+
const byId = new Map(declared.map((d) => [d.id, d]));
|
|
524
|
+
if (cfg.params !== undefined && !isObject(cfg.params)) add(`script node '${n.id}' config.params must be an object`, { nodeId: n.id });
|
|
525
|
+
const values = isObject(cfg.params) ? cfg.params : {};
|
|
526
|
+
for (const [id, value] of Object.entries(values)) {
|
|
527
|
+
const d = byId.get(id);
|
|
528
|
+
if (!d) {
|
|
529
|
+
add(`script node '${n.id}' sets unknown param '${id}' — script "${n.key}" declares ${declared.length ? declared.map((x) => x.id).join(', ') : 'no params'}`, { nodeId: n.id });
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const bad = paramValueError(d, value);
|
|
533
|
+
if (bad) add(`script node '${n.id}' param '${id}': ${bad}`, { nodeId: n.id });
|
|
534
|
+
}
|
|
535
|
+
// `p.meta.inputs` are the RESOLVED inputs — the engine port is already among them, so it is
|
|
536
|
+
// looked up there instead of re-asking hasParamsPort (which would read it as a collision).
|
|
537
|
+
const enginePort = p.ported && p.inputs.some((x) => x && x.id === PARAMS_PORT.id && x.engine === 'params');
|
|
538
|
+
if (cfg.paramsPort !== undefined && typeof cfg.paramsPort !== 'boolean') {
|
|
539
|
+
add(`script node '${n.id}' config.paramsPort must be true or false`, { nodeId: n.id });
|
|
540
|
+
} else if (cfg.paramsPort === true && p.ported && !enginePort) {
|
|
541
|
+
add(wirableParams(meta).length
|
|
542
|
+
? `script node '${n.id}' enables paramsPort but script "${n.key}" already declares an input named '${PARAMS_PORT.id}'`
|
|
543
|
+
: `script node '${n.id}' enables paramsPort but script "${n.key}" declares no param a wire can set`, { nodeId: n.id });
|
|
544
|
+
}
|
|
545
|
+
const wireDelivers = enginePort && isWired(n.id, PARAMS_PORT.id);
|
|
546
|
+
for (const d of declared) {
|
|
547
|
+
if (wireDelivers && WIRABLE_PARAM_TYPES.includes(d.type)) continue; // the wire may deliver it; the runner refuses the execution if it does not
|
|
548
|
+
if (d.required && values[d.id] === undefined && d.default === undefined) {
|
|
549
|
+
add(`script node '${n.id}' is missing required param '${d.id}'`, { nodeId: n.id, incomplete: true });
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (meta.ports !== 'config' && cfg.ports !== undefined) {
|
|
553
|
+
add(`script node '${n.id}' carries config.ports but script "${n.key}" declares its ports in its sidecar`, { nodeId: n.id });
|
|
554
|
+
}
|
|
555
|
+
if (cfg.mock !== undefined) {
|
|
556
|
+
for (const e of mockErrors(cfg.mock, p.ported ? p.outputs : [])) add(`script node '${n.id}' config.mock: ${e}`, { nodeId: n.id });
|
|
557
|
+
}
|
|
558
|
+
if (cfg.timeoutMs !== undefined && (!Number.isInteger(cfg.timeoutMs) || cfg.timeoutMs < MIN_TIMEOUT_MS)) {
|
|
559
|
+
add(`script node '${n.id}' timeoutMs must be an integer >= ${MIN_TIMEOUT_MS} ms (got ${JSON.stringify(cfg.timeoutMs)})`, { nodeId: n.id });
|
|
560
|
+
} else if (cfg.timeoutMs > MAX_TIMEOUT_MS) {
|
|
561
|
+
// v4 T3: a timer delay past 2^31-1 ms fires after ONE millisecond — the card would be killed at once.
|
|
562
|
+
add(`script node '${n.id}' timeoutMs must be at most ${MAX_TIMEOUT_MS} ms (24 h) (got ${cfg.timeoutMs})`, { nodeId: n.id });
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
} },
|
|
493
566
|
];
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Human-hours estimator (money-saved design §4). PURE: no I/O, no builtin imports — shared by
|
|
2
|
+
// the orchestrator (per step), the backfill (per run) and, later, the browser. Every rule
|
|
3
|
+
// reads EVIDENCE of what a step produced; no agent key is ever consulted.
|
|
4
|
+
|
|
5
|
+
export const HUMAN_ESTIMATE_DEFAULTS = Object.freeze({
|
|
6
|
+
codeBase: 0.5, codeFileH: 0.1, codeExp: 0.85, codeDiv: 25, // 0.5 + 0.1·files + lines^0.85/25
|
|
7
|
+
writeBase: 0.25, writeWph: 500, writeCapWords: 6000, // 0.25 + min(words,6000)/500
|
|
8
|
+
reviseFactor: 0.35, reviseDecay: 0.5, // write · 0.35 · 0.5^(cycle−1)
|
|
9
|
+
jsonBase: 0.25, jsonItemH: 0.05, // 0.25 + 0.05·items
|
|
10
|
+
readLph: 300, readWph: 3000, rereadFactor: 0.3, // (lines/300 + words/3000) · (cycle>1 ? 0.3 : 1)
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const finite = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
14
|
+
|
|
15
|
+
/** Round hours to 2 dp (the unit the rate multiplies). */
|
|
16
|
+
export const roundHours = (h) => Math.round(((finite(h) ? h : 0) + Number.EPSILON) * 100) / 100;
|
|
17
|
+
|
|
18
|
+
/** Merge user overrides over the defaults: only finite, non-negative numbers for KNOWN keys apply. */
|
|
19
|
+
export function resolveConstants(overrides) {
|
|
20
|
+
if (!overrides || typeof overrides !== 'object') return HUMAN_ESTIMATE_DEFAULTS;
|
|
21
|
+
const out = { ...HUMAN_ESTIMATE_DEFAULTS };
|
|
22
|
+
for (const key of Object.keys(HUMAN_ESTIMATE_DEFAULTS)) {
|
|
23
|
+
const v = overrides[key];
|
|
24
|
+
if (finite(v) && v >= 0) out[key] = v;
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const FENCE_RE = /```[\s\S]*?```/g;
|
|
30
|
+
|
|
31
|
+
/** Words outside fenced code blocks. Plans embed whole files; a human would not have typed those. */
|
|
32
|
+
export function proseWords(text) {
|
|
33
|
+
if (text == null) return 0;
|
|
34
|
+
return String(text).replace(FENCE_RE, ' ').split(/\s+/).filter(Boolean).length;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Array length, else the first array-valued member's length, else the key count; 0 for scalars. */
|
|
38
|
+
export function jsonItems(value) {
|
|
39
|
+
if (Array.isArray(value)) return value.length;
|
|
40
|
+
if (!value || typeof value !== 'object') return 0;
|
|
41
|
+
const arr = Object.values(value).find(Array.isArray);
|
|
42
|
+
return arr ? arr.length : Object.keys(value).length;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {object} evidence see the module header in human-evidence.mjs for the collector
|
|
47
|
+
* @param {object} [k] resolved constants
|
|
48
|
+
* @returns {{hours:number, signals:{code:number,write:number,revise:number,json:number,read:number}, method:'heuristic'|'override'|'none'}}
|
|
49
|
+
*/
|
|
50
|
+
export function estimateStepHours(evidence, k = HUMAN_ESTIMATE_DEFAULTS) {
|
|
51
|
+
const signals = { code: 0, write: 0, revise: 0, json: 0, read: 0 };
|
|
52
|
+
const e = evidence || {};
|
|
53
|
+
const effort = e.agent?.humanEffort;
|
|
54
|
+
if (effort && finite(effort.hours) && effort.hours >= 0) {
|
|
55
|
+
return { hours: roundHours(effort.hours), signals, method: 'override' };
|
|
56
|
+
}
|
|
57
|
+
if (e.nodeKind !== 'agent' || !e.agent) return { hours: 0, signals, method: 'none' };
|
|
58
|
+
const cycle = Number.isInteger(e.cycle) && e.cycle > 0 ? e.cycle : 1;
|
|
59
|
+
|
|
60
|
+
// 3. code delta
|
|
61
|
+
if (e.code) {
|
|
62
|
+
const lines = (e.code.insertions | 0) + (e.code.deletions | 0);
|
|
63
|
+
if (lines > 0) signals.code = k.codeBase + k.codeFileH * (e.code.files | 0) + Math.pow(lines, k.codeExp) / k.codeDiv;
|
|
64
|
+
}
|
|
65
|
+
// 4. prose: the LARGEST md output only (a plan plus its review note is one activity)
|
|
66
|
+
const mds = (e.outputs || []).filter((o) => o && o.type === 'md');
|
|
67
|
+
if (mds.length) {
|
|
68
|
+
const top = mds.reduce((a, b) => ((b.words | 0) > (a.words | 0) ? b : a));
|
|
69
|
+
const w = k.writeBase + Math.min(top.words | 0, k.writeCapWords) / k.writeWph;
|
|
70
|
+
if (top.revision) signals.revise = w * k.reviseFactor * Math.pow(k.reviseDecay, cycle - 1);
|
|
71
|
+
else signals.write = w;
|
|
72
|
+
}
|
|
73
|
+
// 5. structured outputs
|
|
74
|
+
for (const o of e.outputs || []) {
|
|
75
|
+
if (o && o.type === 'json') signals.json += k.jsonBase + k.jsonItemH * (o.items | 0);
|
|
76
|
+
}
|
|
77
|
+
// 6. reading (verifiers only)
|
|
78
|
+
if (e.agent.runnerType === 'verifier' && e.reads) {
|
|
79
|
+
const base = (e.reads.diffLines | 0) / k.readLph + (e.reads.words | 0) / k.readWph;
|
|
80
|
+
signals.read = base * (cycle > 1 ? k.rereadFactor : 1);
|
|
81
|
+
}
|
|
82
|
+
// 7. total, scaled
|
|
83
|
+
const factor = effort && finite(effort.factor) && effort.factor >= 0 ? effort.factor : 1;
|
|
84
|
+
for (const s of Object.keys(signals)) signals[s] = roundHours(signals[s] * factor);
|
|
85
|
+
const hours = roundHours(Object.values(signals).reduce((a, b) => a + b, 0));
|
|
86
|
+
return { hours, signals, method: 'heuristic' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Σ finite step.humanHours, 2 dp. Mirrors run-harness sumStepCosts. */
|
|
90
|
+
export function sumStepHours(steps) {
|
|
91
|
+
let sum = 0;
|
|
92
|
+
for (const s of Array.isArray(steps) ? steps : []) if (finite(s?.humanHours)) sum += s.humanHours;
|
|
93
|
+
return roundHours(sum);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** hours × rate − spent, 2 dp; negative is a real (honest) outcome. */
|
|
97
|
+
export function savedUsd(hours, rateUsd, spentUsd) {
|
|
98
|
+
const v = (finite(hours) ? hours : 0) * (finite(rateUsd) ? rateUsd : 0) - (finite(spentUsd) ? spentUsd : 0);
|
|
99
|
+
return Math.round((v + Number.EPSILON) * 100) / 100;
|
|
100
|
+
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// src/shared/schedule/recurrence.mjs
|
|
2
|
+
// Recurrence rules for scheduled runs — ONE source for the server, the CLI and the
|
|
3
|
+
// browser (served under /src/shared, like the graph model). Pure: no node: imports,
|
|
4
|
+
// no Date.now() — every function takes its instant.
|
|
5
|
+
//
|
|
6
|
+
// A rule is wall-clock time in an IANA timezone, never a UTC instant: "02:00 every
|
|
7
|
+
// night" must stay 02:00 across daylight-saving changes.
|
|
8
|
+
//
|
|
9
|
+
// { freq: 'daily'|'weekly'|'monthly', interval: 1..,
|
|
10
|
+
// weekdays: ['mo',..] (weekly)
|
|
11
|
+
// monthDay: 1..31 | 'last' (monthly)
|
|
12
|
+
// time: 'HH:MM', tz: 'Europe/Berlin',
|
|
13
|
+
// anchor: 'YYYY-MM-DD' (local date the interval counts from)
|
|
14
|
+
// end: { type:'never' } | { type:'until', until:'YYYY-MM-DD' } | { type:'count', count:n } }
|
|
15
|
+
|
|
16
|
+
export const WEEKDAYS = ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'];
|
|
17
|
+
const WD_LONG = { mo: 'Monday', tu: 'Tuesday', we: 'Wednesday', th: 'Thursday', fr: 'Friday', sa: 'Saturday', su: 'Sunday' };
|
|
18
|
+
const WD_SHORT = { mo: 'Mon', tu: 'Tue', we: 'Wed', th: 'Thu', fr: 'Fri', sa: 'Sat', su: 'Sun' };
|
|
19
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
20
|
+
export const OVERLAP_POLICIES = ['skip', 'start', 'queue'];
|
|
21
|
+
export const MISSED_POLICIES = ['run', 'skip'];
|
|
22
|
+
const TIME_RE = /^([01]?\d|2[0-3]):([0-5]\d)$/;
|
|
23
|
+
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
24
|
+
const MAX_SCAN_DAYS = 366 * 12;
|
|
25
|
+
|
|
26
|
+
/** True when `tz` is a timezone Intl accepts. */
|
|
27
|
+
export function isValidTimeZone(tz) {
|
|
28
|
+
if (typeof tz !== 'string' || !tz) return false;
|
|
29
|
+
try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const _fmtCache = new Map();
|
|
33
|
+
function partsFormatter(tz) {
|
|
34
|
+
let f = _fmtCache.get(tz);
|
|
35
|
+
if (!f) {
|
|
36
|
+
f = new Intl.DateTimeFormat('en-US', {
|
|
37
|
+
timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit',
|
|
38
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
39
|
+
});
|
|
40
|
+
_fmtCache.set(tz, f);
|
|
41
|
+
}
|
|
42
|
+
return f;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Wall-clock parts of instant `ms` in `tz`: { y, m (1-12), d, hh, mm, ss }. */
|
|
46
|
+
export function zonedParts(ms, tz) {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const p of partsFormatter(tz).formatToParts(new Date(ms))) {
|
|
49
|
+
if (p.type !== 'literal') out[p.type] = Number(p.value);
|
|
50
|
+
}
|
|
51
|
+
return { y: out.year, m: out.month, d: out.day, hh: out.hour % 24, mm: out.minute, ss: out.second };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Offset (ms) of `tz` from UTC at instant `ms` (positive east of Greenwich). */
|
|
55
|
+
function tzOffsetMs(ms, tz) {
|
|
56
|
+
const p = zonedParts(ms, tz);
|
|
57
|
+
return Date.UTC(p.y, p.m - 1, p.d, p.hh, p.mm, p.ss) - Math.floor(ms / 1000) * 1000;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The UTC instant of a wall-clock time in `tz`. A time that does not exist (spring
|
|
62
|
+
* forward) moves FORWARD past the gap; a time that occurs twice (autumn) resolves to
|
|
63
|
+
* the FIRST occurrence, so a rule fires once.
|
|
64
|
+
*/
|
|
65
|
+
export function zonedToUtc({ y, m, d, hh, mm }, tz) {
|
|
66
|
+
const wall = Date.UTC(y, m - 1, d, hh, mm, 0);
|
|
67
|
+
// The offsets a day either side of the wall time cover every transition.
|
|
68
|
+
const offs = [...new Set([tzOffsetMs(wall - 86400000, tz), tzOffsetMs(wall + 86400000, tz), tzOffsetMs(wall, tz)])];
|
|
69
|
+
const hits = [];
|
|
70
|
+
for (const off of offs) {
|
|
71
|
+
const t = wall - off;
|
|
72
|
+
const p = zonedParts(t, tz);
|
|
73
|
+
if (p.y === y && p.m === m && p.d === d && p.hh === hh && p.mm === mm) hits.push(t);
|
|
74
|
+
}
|
|
75
|
+
if (hits.length) return Math.min(...hits);
|
|
76
|
+
// Non-existent local time: read it with the offset in force BEFORE the jump, which
|
|
77
|
+
// lands the same distance past the gap.
|
|
78
|
+
return wall - Math.min(...offs);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function daysInMonth(y, m) { return new Date(Date.UTC(y, m, 0)).getUTCDate(); }
|
|
82
|
+
function dowIndex(y, m, d) { return (new Date(Date.UTC(y, m - 1, d)).getUTCDay() + 6) % 7; } // Monday = 0
|
|
83
|
+
function weekdayKey(y, m, d) { return WEEKDAYS[dowIndex(y, m, d)]; }
|
|
84
|
+
function civilDayNumber(y, m, d) { return Math.floor(Date.UTC(y, m - 1, d) / 86400000); }
|
|
85
|
+
function parseDate(s) { const r = DATE_RE.exec(String(s || '')); return r ? { y: +r[1], m: +r[2], d: +r[3] } : null; }
|
|
86
|
+
function pad(n) { return String(n).padStart(2, '0'); }
|
|
87
|
+
/** 'YYYY-MM-DD' of civil parts. */
|
|
88
|
+
export function civilDate({ y, m, d }) { return `${y}-${pad(m)}-${pad(d)}`; }
|
|
89
|
+
/** Today's local date ('YYYY-MM-DD') in `tz` at instant `ms`. */
|
|
90
|
+
export function localDate(ms, tz) { return civilDate(zonedParts(ms, tz)); }
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Validate + normalise a rule. Returns { ok:true, rule } or { ok:false, error }.
|
|
94
|
+
* `todayLocal` ('YYYY-MM-DD') seeds a missing anchor.
|
|
95
|
+
*/
|
|
96
|
+
export function normalizeRule(input, { todayLocal = null } = {}) {
|
|
97
|
+
const r = input && typeof input === 'object' && !Array.isArray(input) ? input : null;
|
|
98
|
+
if (!r) return { ok: false, error: 'repeat.rule must be an object' };
|
|
99
|
+
const freq = String(r.freq || '').toLowerCase();
|
|
100
|
+
if (!['daily', 'weekly', 'monthly'].includes(freq)) return { ok: false, error: 'rule.freq must be daily, weekly or monthly' };
|
|
101
|
+
const interval = r.interval == null ? 1 : Number(r.interval);
|
|
102
|
+
if (!Number.isInteger(interval) || interval < 1 || interval > 365) return { ok: false, error: 'rule.interval must be a whole number from 1 to 365' };
|
|
103
|
+
const tm = TIME_RE.exec(String(r.time || ''));
|
|
104
|
+
if (!tm) return { ok: false, error: 'rule.time must be HH:MM (24-hour)' };
|
|
105
|
+
const time = `${pad(+tm[1])}:${tm[2]}`;
|
|
106
|
+
if (!isValidTimeZone(r.tz)) return { ok: false, error: `rule.tz is not a known timezone: ${r.tz ?? '(missing)'}` };
|
|
107
|
+
const out = { freq, interval, time, tz: r.tz };
|
|
108
|
+
if (freq === 'weekly') {
|
|
109
|
+
const days = Array.isArray(r.weekdays) ? [...new Set(r.weekdays.map((x) => String(x).toLowerCase().slice(0, 2)))] : [];
|
|
110
|
+
if (days.some((x) => !WEEKDAYS.includes(x))) return { ok: false, error: 'rule.weekdays must use mo tu we th fr sa su' };
|
|
111
|
+
if (!days.length) return { ok: false, error: 'rule.weekdays needs at least one day' };
|
|
112
|
+
out.weekdays = WEEKDAYS.filter((x) => days.includes(x));
|
|
113
|
+
}
|
|
114
|
+
if (freq === 'monthly') {
|
|
115
|
+
const md = r.monthDay === 'last' ? 'last' : Number(r.monthDay);
|
|
116
|
+
if (md !== 'last' && (!Number.isInteger(md) || md < 1 || md > 31)) return { ok: false, error: 'rule.monthDay must be 1-31 or "last"' };
|
|
117
|
+
out.monthDay = md;
|
|
118
|
+
}
|
|
119
|
+
const anchor = parseDate(r.anchor) ? r.anchor : todayLocal;
|
|
120
|
+
if (!parseDate(anchor)) return { ok: false, error: 'rule.anchor must be YYYY-MM-DD' };
|
|
121
|
+
out.anchor = anchor;
|
|
122
|
+
const end = r.end && typeof r.end === 'object' ? r.end : { type: 'never' };
|
|
123
|
+
const et = String(end.type || 'never');
|
|
124
|
+
if (et === 'never') out.end = { type: 'never' };
|
|
125
|
+
else if (et === 'until') {
|
|
126
|
+
if (!parseDate(end.until)) return { ok: false, error: 'rule.end.until must be YYYY-MM-DD' };
|
|
127
|
+
out.end = { type: 'until', until: end.until };
|
|
128
|
+
} else if (et === 'count') {
|
|
129
|
+
const c = Number(end.count);
|
|
130
|
+
if (!Number.isInteger(c) || c < 1 || c > 100000) return { ok: false, error: 'rule.end.count must be a whole number of 1 or more' };
|
|
131
|
+
out.end = { type: 'count', count: c };
|
|
132
|
+
} else return { ok: false, error: 'rule.end.type must be never, until or count' };
|
|
133
|
+
return { ok: true, rule: out };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function matchesDate(rule, y, m, d, anchor) {
|
|
137
|
+
if (rule.freq === 'daily') {
|
|
138
|
+
const diff = civilDayNumber(y, m, d) - civilDayNumber(anchor.y, anchor.m, anchor.d);
|
|
139
|
+
return diff >= 0 && diff % rule.interval === 0;
|
|
140
|
+
}
|
|
141
|
+
if (rule.freq === 'weekly') {
|
|
142
|
+
if (!rule.weekdays.includes(weekdayKey(y, m, d))) return false;
|
|
143
|
+
if (civilDayNumber(y, m, d) < civilDayNumber(anchor.y, anchor.m, anchor.d)) return false;
|
|
144
|
+
// Week index counted from the Monday of the anchor's week.
|
|
145
|
+
const monday = (yy, mm2, dd) => civilDayNumber(yy, mm2, dd) - dowIndex(yy, mm2, dd);
|
|
146
|
+
const diff = (monday(y, m, d) - monday(anchor.y, anchor.m, anchor.d)) / 7;
|
|
147
|
+
return diff % rule.interval === 0;
|
|
148
|
+
}
|
|
149
|
+
// monthly — a day past the month's end clamps to its last day.
|
|
150
|
+
const months = (y - anchor.y) * 12 + (m - anchor.m);
|
|
151
|
+
if (months < 0 || months % rule.interval !== 0) return false;
|
|
152
|
+
const dim = daysInMonth(y, m);
|
|
153
|
+
const want = rule.monthDay === 'last' ? dim : Math.min(rule.monthDay, dim);
|
|
154
|
+
return d === want;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The first occurrence strictly after `afterMs`, as a UTC ms instant, or null when
|
|
159
|
+
* the rule has ended (`firedCount` feeds end.type 'count').
|
|
160
|
+
*/
|
|
161
|
+
export function nextOccurrence(rule, afterMs, { firedCount = 0 } = {}) {
|
|
162
|
+
if (rule.end?.type === 'count' && firedCount >= rule.end.count) return null;
|
|
163
|
+
const anchor = parseDate(rule.anchor);
|
|
164
|
+
const [hh, mm] = rule.time.split(':').map(Number);
|
|
165
|
+
const until = rule.end?.type === 'until' ? parseDate(rule.end.until) : null;
|
|
166
|
+
const start = zonedParts(afterMs, rule.tz);
|
|
167
|
+
let dayNo = civilDayNumber(start.y, start.m, start.d);
|
|
168
|
+
for (let i = 0; i < MAX_SCAN_DAYS; i++, dayNo++) {
|
|
169
|
+
const dt = new Date(dayNo * 86400000);
|
|
170
|
+
const y = dt.getUTCFullYear(), m = dt.getUTCMonth() + 1, d = dt.getUTCDate();
|
|
171
|
+
if (until && dayNo > civilDayNumber(until.y, until.m, until.d)) return null;
|
|
172
|
+
if (!matchesDate(rule, y, m, d, anchor)) continue;
|
|
173
|
+
const t = zonedToUtc({ y, m, d, hh, mm }, rule.tz);
|
|
174
|
+
if (t > afterMs) return t;
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The next `n` occurrences after `afterMs` (for the editor preview). */
|
|
180
|
+
export function previewOccurrences(rule, afterMs, n = 3, { firedCount = 0 } = {}) {
|
|
181
|
+
const out = [];
|
|
182
|
+
let t = afterMs, fired = firedCount;
|
|
183
|
+
while (out.length < n) {
|
|
184
|
+
const next = nextOccurrence(rule, t, { firedCount: fired });
|
|
185
|
+
if (next == null) break;
|
|
186
|
+
out.push(next); t = next; fired++;
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function listJoin(xs) { return xs.length <= 1 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`; }
|
|
192
|
+
function ordinal(n) { const s = ['th', 'st', 'nd', 'rd'], v = n % 100; return n + (s[(v - 20) % 10] || s[v] || s[0]); }
|
|
193
|
+
|
|
194
|
+
/** The plain-language sentence of a rule: "Every weekday at 02:00". */
|
|
195
|
+
export function describeRule(rule, { withTz = false } = {}) {
|
|
196
|
+
if (!rule) return '';
|
|
197
|
+
const n = rule.interval || 1;
|
|
198
|
+
let head;
|
|
199
|
+
if (rule.freq === 'daily') head = n === 1 ? 'Every day' : `Every ${n} days`;
|
|
200
|
+
else if (rule.freq === 'weekly') {
|
|
201
|
+
const days = rule.weekdays || [];
|
|
202
|
+
const key = days.join(',');
|
|
203
|
+
if (key === 'mo,tu,we,th,fr') head = n === 1 ? 'Every weekday' : `Every ${n} weeks on weekdays`;
|
|
204
|
+
else if (days.length === 7) head = n === 1 ? 'Every day' : `Every ${n} weeks, every day`;
|
|
205
|
+
else head = `${n === 1 ? 'Every' : `Every ${n} weeks on`} ${listJoin(days.map((d) => WD_LONG[d]))}`;
|
|
206
|
+
} else {
|
|
207
|
+
const day = rule.monthDay === 'last' ? 'the last day' : `the ${ordinal(rule.monthDay)}`;
|
|
208
|
+
head = n === 1 ? `Every month on ${day}` : `Every ${n} months on ${day}`;
|
|
209
|
+
}
|
|
210
|
+
let s = `${head} at ${rule.time}`;
|
|
211
|
+
if (rule.end?.type === 'until') s += `, until ${rule.end.until}`;
|
|
212
|
+
if (rule.end?.type === 'count') s += `, ${rule.end.count} time${rule.end.count === 1 ? '' : 's'}`;
|
|
213
|
+
if (withTz) s += ` (${rule.tz})`;
|
|
214
|
+
return s;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** "Sat Sep 19, 02:00" in `tz` — the app's hand-rolled local format (app.js fmtResetAtLocal). */
|
|
218
|
+
export function formatInstant(ms, tz, { withYear = false } = {}) {
|
|
219
|
+
const p = zonedParts(ms, tz);
|
|
220
|
+
const wd = WD_SHORT[weekdayKey(p.y, p.m, p.d)];
|
|
221
|
+
return `${wd} ${MONTHS[p.m - 1]} ${p.d}${withYear ? ` ${p.y}` : ''}, ${pad(p.hh)}:${pad(p.mm)}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** "7h 12m" / "3d 4h" / "45s" — the distance to a future instant ('' when past). */
|
|
225
|
+
export function formatCountdown(ms) {
|
|
226
|
+
if (!Number.isFinite(ms) || ms <= 0) return '';
|
|
227
|
+
const s = Math.floor(ms / 1000);
|
|
228
|
+
if (s < 60) return `${s}s`;
|
|
229
|
+
const m = Math.floor(s / 60);
|
|
230
|
+
if (m < 60) return `${m}m`;
|
|
231
|
+
const h = Math.floor(m / 60);
|
|
232
|
+
if (h < 48) return `${h}h ${m % 60}m`;
|
|
233
|
+
return `${Math.floor(h / 24)}d ${h % 24}h`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Parse the CLI's `--every` shorthand into a rule (tz/anchor filled by the caller):
|
|
238
|
+
* "day 03:30" · "3 days 02:00" · "weekdays 02:00" · "weekends 09:00"
|
|
239
|
+
* "mon,wed,fri 02:00" · "2 weeks mon 02:00" · "month 1 02:00" · "3 months last 02:00"
|
|
240
|
+
*/
|
|
241
|
+
export function parseEvery(text) {
|
|
242
|
+
const toks = String(text || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
243
|
+
if (toks.length < 2) return { ok: false, error: '--every needs a pattern and a time, e.g. "weekdays 02:00"' };
|
|
244
|
+
const time = toks.pop();
|
|
245
|
+
if (!TIME_RE.test(time)) return { ok: false, error: `--every: "${time}" is not a HH:MM time` };
|
|
246
|
+
let interval = 1;
|
|
247
|
+
if (/^\d+$/.test(toks[0])) interval = Number(toks.shift());
|
|
248
|
+
if (!toks.length) return { ok: false, error: '--every: missing unit (day, week, month, weekdays, or day names)' };
|
|
249
|
+
const unit = toks.shift();
|
|
250
|
+
const days = (s) => s.split(',').map((x) => x.trim().slice(0, 2)).filter(Boolean);
|
|
251
|
+
if (['day', 'days', 'daily', 'night', 'nightly'].includes(unit) && !toks.length) return { ok: true, rule: { freq: 'daily', interval, time } };
|
|
252
|
+
if (unit === 'weekdays' && !toks.length) return { ok: true, rule: { freq: 'weekly', interval, weekdays: ['mo', 'tu', 'we', 'th', 'fr'], time } };
|
|
253
|
+
if (unit === 'weekends' && !toks.length) return { ok: true, rule: { freq: 'weekly', interval, weekdays: ['sa', 'su'], time } };
|
|
254
|
+
if (['week', 'weeks', 'weekly'].includes(unit)) {
|
|
255
|
+
if (toks.length !== 1) return { ok: false, error: '--every: name the day(s), e.g. "week mon 02:00" or "2 weeks mon,thu 02:00"' };
|
|
256
|
+
const d = days(toks[0]);
|
|
257
|
+
if (!d.length || d.some((x) => !WEEKDAYS.includes(x))) return { ok: false, error: `--every: unknown day in "${toks[0]}"` };
|
|
258
|
+
return { ok: true, rule: { freq: 'weekly', interval, weekdays: d, time } };
|
|
259
|
+
}
|
|
260
|
+
if (['month', 'months', 'monthly'].includes(unit)) {
|
|
261
|
+
if (toks.length !== 1) return { ok: false, error: '--every: name the day of the month, e.g. "month 1 02:00" or "month last 02:00"' };
|
|
262
|
+
const md = toks[0] === 'last' ? 'last' : Number(toks[0]);
|
|
263
|
+
if (md !== 'last' && (!Number.isInteger(md) || md < 1 || md > 31)) return { ok: false, error: `--every: "${toks[0]}" is not a day of the month` };
|
|
264
|
+
return { ok: true, rule: { freq: 'monthly', interval, monthDay: md, time } };
|
|
265
|
+
}
|
|
266
|
+
if (!toks.length) {
|
|
267
|
+
const d = days(unit);
|
|
268
|
+
if (d.length && d.every((x) => WEEKDAYS.includes(x))) return { ok: true, rule: { freq: 'weekly', interval, weekdays: d, time } };
|
|
269
|
+
}
|
|
270
|
+
return { ok: false, error: `--every: cannot read "${text}" — try "day 03:30", "weekdays 02:00", "mon,thu 02:00" or "month 1 02:00"` };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Translate a 5-field cron line when a rule can express it (fixed minute + hour, and
|
|
275
|
+
* either a day-of-week set or one day-of-month). Anything else is refused with a hint.
|
|
276
|
+
*/
|
|
277
|
+
export function parseCron(text) {
|
|
278
|
+
const f = String(text || '').trim().split(/\s+/);
|
|
279
|
+
if (f.length !== 5) return { ok: false, error: '--cron needs five fields: minute hour day-of-month month day-of-week' };
|
|
280
|
+
const [min, hour, dom, mon, dow] = f;
|
|
281
|
+
const hint = ' — only a fixed time with day-of-week or one day-of-month is supported; use --every for the rest';
|
|
282
|
+
if (!/^\d{1,2}$/.test(min) || !/^\d{1,2}$/.test(hour) || +min > 59 || +hour > 23) return { ok: false, error: `--cron: minute and hour must be single numbers${hint}` };
|
|
283
|
+
if (mon !== '*') return { ok: false, error: `--cron: a month field is not supported${hint}` };
|
|
284
|
+
const time = `${pad(+hour)}:${pad(+min)}`;
|
|
285
|
+
if (dom === '*' && dow === '*') return { ok: true, rule: { freq: 'daily', interval: 1, time } };
|
|
286
|
+
if (dom === '*') {
|
|
287
|
+
const set = new Set();
|
|
288
|
+
for (const part of dow.split(',')) {
|
|
289
|
+
const r = /^(\d)(?:-(\d))?$/.exec(part);
|
|
290
|
+
if (!r) return { ok: false, error: `--cron: cannot read day-of-week "${dow}"${hint}` };
|
|
291
|
+
const a = +r[1], b = r[2] == null ? a : +r[2];
|
|
292
|
+
if (a > 7 || b > 7 || b < a) return { ok: false, error: `--cron: cannot read day-of-week "${dow}"${hint}` };
|
|
293
|
+
for (let i = a; i <= b; i++) set.add(WEEKDAYS[((i % 7) + 6) % 7]); // cron 0/7 = Sunday
|
|
294
|
+
}
|
|
295
|
+
return { ok: true, rule: { freq: 'weekly', interval: 1, weekdays: WEEKDAYS.filter((d) => set.has(d)), time } };
|
|
296
|
+
}
|
|
297
|
+
if (dow === '*' && /^\d{1,2}$/.test(dom) && +dom >= 1 && +dom <= 31) return { ok: true, rule: { freq: 'monthly', interval: 1, monthDay: +dom, time } };
|
|
298
|
+
return { ok: false, error: `--cron: this pattern cannot be expressed${hint}` };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Parse a one-shot `--at` value into a UTC ms instant. Local forms are read in `tz`:
|
|
303
|
+
* "02:00" (next occurrence) · "today 22:00" · "tomorrow 02:00" · "+90m" "+2h" "+1d"
|
|
304
|
+
* "2026-09-19 02:00" · "2026-09-19T02:00" · ISO 8601 with an offset or Z
|
|
305
|
+
*/
|
|
306
|
+
export function parseAt(text, { nowMs, tz }) {
|
|
307
|
+
const s = String(text || '').trim();
|
|
308
|
+
if (!s) return { ok: false, error: '--at needs a time' };
|
|
309
|
+
const rel = /^\+(\d+)\s*(s|sec|secs|m|min|mins|h|hr|hrs|d|day|days)$/i.exec(s);
|
|
310
|
+
if (rel) {
|
|
311
|
+
const n = Number(rel[1]); const u = rel[2][0].toLowerCase();
|
|
312
|
+
return { ok: true, ms: nowMs + n * (u === 's' ? 1000 : u === 'm' ? 60000 : u === 'h' ? 3600000 : 86400000) };
|
|
313
|
+
}
|
|
314
|
+
if (/\d{4}-\d{2}-\d{2}T/.test(s) && /([zZ]|[+-]\d{2}:?\d{2})$/.test(s)) {
|
|
315
|
+
const ms = Date.parse(s);
|
|
316
|
+
return Number.isFinite(ms) ? { ok: true, ms } : { ok: false, error: `--at: cannot read "${s}"` };
|
|
317
|
+
}
|
|
318
|
+
const now = zonedParts(nowMs, tz);
|
|
319
|
+
let r = /^(?:(today|tomorrow)\s+)?(\d{1,2}:\d{2})$/i.exec(s);
|
|
320
|
+
if (r) {
|
|
321
|
+
const tm = TIME_RE.exec(r[2]);
|
|
322
|
+
if (!tm) return { ok: false, error: `--at: "${r[2]}" is not a HH:MM time` };
|
|
323
|
+
const base = civilDayNumber(now.y, now.m, now.d) + (String(r[1] || '').toLowerCase() === 'tomorrow' ? 1 : 0);
|
|
324
|
+
const mk = (dayNo) => {
|
|
325
|
+
const dt = new Date(dayNo * 86400000);
|
|
326
|
+
return zonedToUtc({ y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate(), hh: +tm[1], mm: +tm[2] }, tz);
|
|
327
|
+
};
|
|
328
|
+
let ms = mk(base);
|
|
329
|
+
if (!r[1] && ms <= nowMs) ms = mk(base + 1); // a bare time means its NEXT occurrence
|
|
330
|
+
return { ok: true, ms };
|
|
331
|
+
}
|
|
332
|
+
r = /^(\d{4})-(\d{2})-(\d{2})[T\s](\d{1,2}):(\d{2})(?::\d{2})?$/.exec(s);
|
|
333
|
+
if (r) {
|
|
334
|
+
if (+r[4] > 23 || +r[5] > 59) return { ok: false, error: `--at: "${s}" is not a valid time` };
|
|
335
|
+
return { ok: true, ms: zonedToUtc({ y: +r[1], m: +r[2], d: +r[3], hh: +r[4], mm: +r[5] }, tz) };
|
|
336
|
+
}
|
|
337
|
+
return { ok: false, error: `--at: cannot read "${s}" — try "02:00", "tomorrow 02:00", "+90m" or "2026-09-19 02:00"` };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Parse an API `scheduledFor`: ISO 8601 WITH an offset or Z only — the server cannot
|
|
342
|
+
* know the caller's zone. Returns { ok, ms } or { ok:false, error }.
|
|
343
|
+
*/
|
|
344
|
+
export function parseScheduledFor(value) {
|
|
345
|
+
if (typeof value !== 'string' || !value.trim()) return { ok: false, error: 'scheduledFor must be an ISO 8601 string' };
|
|
346
|
+
const s = value.trim();
|
|
347
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?([zZ]|[+-]\d{2}:?\d{2})$/.test(s)) {
|
|
348
|
+
return { ok: false, error: 'scheduledFor must be ISO 8601 with a UTC offset or Z, e.g. 2026-09-19T02:00:00+02:00' };
|
|
349
|
+
}
|
|
350
|
+
const ms = Date.parse(s);
|
|
351
|
+
if (!Number.isFinite(ms)) return { ok: false, error: 'scheduledFor is not a valid date' };
|
|
352
|
+
return { ok: true, ms };
|
|
353
|
+
}
|