@worca/app 1.4.0-rc.1 → 1.5.0-rc.2
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 +36 -1
- package/docker/.env.example +54 -0
- package/docker/compose.clonein.yml +24 -0
- package/docker/compose.dev.yml +24 -0
- package/docker/compose.egress.yml +47 -0
- package/docker/compose.ssh.yml +17 -0
- package/docker/compose.teams.yml +25 -0
- package/docker/compose.yml +57 -0
- package/package.json +4 -1
- package/src/cli/container.mjs +227 -0
- package/src/cli/models.mjs +5 -4
- package/src/cli/schedule.mjs +23 -10
- package/src/cli/worca-cc.mjs +62 -4
- package/src/core/agent-user.mjs +92 -0
- package/src/core/artifacts.mjs +41 -9
- package/src/core/ask/clone-deps.mjs +25 -0
- package/src/core/ask/clone-proposal.mjs +79 -0
- package/src/core/ask/events.mjs +10 -0
- package/src/core/ask/mcp-stdio.mjs +4 -2
- package/src/core/ask/model-proposal.mjs +14 -3
- package/src/core/ask/prompt.mjs +27 -6
- package/src/core/ask/schedule-deps.mjs +16 -11
- package/src/core/ask/spawn.mjs +8 -3
- package/src/core/ask/store.mjs +40 -10
- package/src/core/ask/tool-deps.mjs +36 -1
- package/src/core/ask/tools.mjs +69 -8
- package/src/core/ask/turn.mjs +31 -1
- package/src/core/bridge/errors.mjs +50 -2
- package/src/core/bridge/provider-ops.mjs +20 -7
- package/src/core/bridge/providers/copilot.mjs +51 -5
- package/src/core/bridge/translate/common.mjs +144 -0
- package/src/core/bridge/translate/request.mjs +16 -73
- package/src/core/bridge/translate/responses-request.mjs +185 -0
- package/src/core/bridge/translate/responses-stream.mjs +327 -0
- package/src/core/bridge/upstream.mjs +37 -10
- package/src/core/cf-access.mjs +90 -0
- package/src/core/chat/command-router.mjs +17 -12
- package/src/core/claude-runner.mjs +26 -3
- package/src/core/clone-project.mjs +139 -0
- package/src/core/config.mjs +16 -9
- package/src/core/db.mjs +60 -5
- package/src/core/deployment.mjs +33 -0
- package/src/core/diff-comments.mjs +17 -8
- package/src/core/git-info.mjs +27 -7
- package/src/core/github-app.mjs +107 -0
- package/src/core/github-credentials.mjs +93 -0
- package/src/core/graph/executor.mjs +22 -4
- package/src/core/graph/script-runner.mjs +24 -3
- package/src/core/guardrails.mjs +1 -0
- package/src/core/identity.mjs +83 -0
- package/src/core/metrics/pr-events-workflow.yml +127 -0
- package/src/core/metrics/prs.mjs +383 -0
- package/src/core/metrics/read.mjs +39 -29
- package/src/core/metrics/record.mjs +30 -2
- package/src/core/metrics/sync.mjs +35 -4
- package/src/core/model-env.mjs +47 -7
- package/src/core/model-test.mjs +29 -5
- package/src/core/notifications.mjs +36 -14
- package/src/core/orchestrator.mjs +10 -4
- package/src/core/phases.mjs +2 -0
- package/src/core/pipeline-delete.mjs +4 -3
- package/src/core/plugin-repo.mjs +7 -3
- package/src/core/policy/gate.mjs +9 -5
- package/src/core/policy/state.mjs +2 -2
- package/src/core/policy/sync.mjs +5 -4
- package/src/core/remote-access.mjs +138 -0
- package/src/core/run-harness.mjs +71 -9
- package/src/core/scheduler.mjs +63 -33
- package/src/shared/team-metrics/timeline.mjs +330 -0
- package/ui/public/app.js +591 -14
- package/ui/public/ask-panel.mjs +65 -3
- package/ui/public/bridge-view.mjs +72 -14
- package/ui/public/index.html +42 -9
- package/ui/public/schedules-view.mjs +21 -2
- package/ui/public/session-guard.mjs +77 -0
- package/ui/public/style.css +171 -1
- package/ui/public/team-metrics-timeline.mjs +480 -0
- package/ui/public/team-metrics-view.mjs +10 -2
- package/ui/public/team-policy-view.mjs +6 -1
- package/ui/server.mjs +454 -104
package/src/core/run-harness.mjs
CHANGED
|
@@ -79,6 +79,7 @@ import { fieldsForRun, effectiveCap, deviationsFor } from './policy/effective.mj
|
|
|
79
79
|
import { writePolicyState, hasPipelineOverride, readTotalAck } from './policy/state.mjs';
|
|
80
80
|
import { installedPluginsMap, WORCA_VERSION as POLICY_WORCA_VERSION } from './policy/local.mjs';
|
|
81
81
|
import { readSettings as readRawSettings } from './settings.mjs';
|
|
82
|
+
import { byActor } from './identity.mjs';
|
|
82
83
|
|
|
83
84
|
// worca-cc repo root; holds skills/. fileURLToPath, never URL.pathname: the
|
|
84
85
|
// latter is `/C:/…` on Windows and %-encoded everywhere (see DEFAULT_AGENTS_DIR
|
|
@@ -687,6 +688,7 @@ export class RunHarness extends EventEmitter {
|
|
|
687
688
|
this.mcpServerGrants = []; // `mcp__<server>` per merged server (V1 branch (a))
|
|
688
689
|
|
|
689
690
|
this.abort = new AbortController();
|
|
691
|
+
this._answeredBy = new Map(); // question id -> who answered it (identity.mjs actor)
|
|
690
692
|
this.pauseRequested = false;
|
|
691
693
|
this.pauseAbort = new AbortController(); // aborts ONLY node children on pause
|
|
692
694
|
this.pauseReason = null; // WHY the run paused: 'cost_pipeline'|'cost_total'|'error'|<usage-limit line>|null
|
|
@@ -794,8 +796,14 @@ export class RunHarness extends EventEmitter {
|
|
|
794
796
|
* @param {string} id
|
|
795
797
|
* @param {object} payload clarify: {answers:[{id,choice}]} ; gate: {decision}
|
|
796
798
|
*/
|
|
797
|
-
answer(id, payload) {
|
|
799
|
+
answer(id, payload, by = null) {
|
|
798
800
|
const pq = this.pendingQuestion;
|
|
801
|
+
// Who answered (identity.mjs actor): kept per question id for the writers that store
|
|
802
|
+
// the answer (answeredBy on the row) and audited for decisions below.
|
|
803
|
+
const settle = (value) => {
|
|
804
|
+
if (typeof by === 'string' && by) this._answeredBy.set(id, by);
|
|
805
|
+
this._auditDecision(pq, value, typeof by === 'string' && by ? by : null);
|
|
806
|
+
};
|
|
799
807
|
if (!pq || pq.id !== id) {
|
|
800
808
|
this._log('orchestrator', 'warn', `answer() ignored: no pending question with id ${id}`);
|
|
801
809
|
return false;
|
|
@@ -815,6 +823,7 @@ export class RunHarness extends EventEmitter {
|
|
|
815
823
|
throw err;
|
|
816
824
|
}
|
|
817
825
|
this.pendingQuestion = null;
|
|
826
|
+
settle(out.payload);
|
|
818
827
|
pq.resolve(out.payload);
|
|
819
828
|
return true;
|
|
820
829
|
}
|
|
@@ -823,17 +832,21 @@ export class RunHarness extends EventEmitter {
|
|
|
823
832
|
return false;
|
|
824
833
|
}
|
|
825
834
|
this.pendingQuestion = null;
|
|
835
|
+
settle(out);
|
|
826
836
|
pq.resolve(out);
|
|
827
837
|
return true;
|
|
828
838
|
}
|
|
829
839
|
this.pendingQuestion = null;
|
|
840
|
+
settle(payload);
|
|
830
841
|
pq.resolve(payload);
|
|
831
842
|
return true;
|
|
832
843
|
}
|
|
833
844
|
|
|
834
|
-
/** Abort the run; marks state stopped and kills any child via the signal.
|
|
835
|
-
|
|
845
|
+
/** Abort the run; marks state stopped and kills any child via the signal. `by` = who
|
|
846
|
+
* asked (identity.mjs actor), recorded as state.lastAction before the status event. */
|
|
847
|
+
stop(by = null) {
|
|
836
848
|
if (this.state.status === 'done' || this.state.status === 'stopped') return;
|
|
849
|
+
this._recordAction('stop', by);
|
|
837
850
|
this._setStatus('stopped');
|
|
838
851
|
try {
|
|
839
852
|
this.abort.abort();
|
|
@@ -855,8 +868,9 @@ export class RunHarness extends EventEmitter {
|
|
|
855
868
|
* pause-only signal), unwind _dispatch, persist a resume point. The worktree is
|
|
856
869
|
* kept. Returns false unless the run is currently 'running'.
|
|
857
870
|
*/
|
|
858
|
-
pause() {
|
|
871
|
+
pause(by = null) {
|
|
859
872
|
if (this.state.status !== 'running') return false;
|
|
873
|
+
this._recordAction('pause', by);
|
|
860
874
|
this.pauseRequested = true;
|
|
861
875
|
this._setStatus('pausing');
|
|
862
876
|
try {
|
|
@@ -873,6 +887,39 @@ export class RunHarness extends EventEmitter {
|
|
|
873
887
|
return true;
|
|
874
888
|
}
|
|
875
889
|
|
|
890
|
+
/** Who stopped / paused / resumed the run (identity.mjs actor): { kind, by, at } on the
|
|
891
|
+
* state, so every `state` event, getState() and the resume point carry it. */
|
|
892
|
+
_recordAction(kind, by) {
|
|
893
|
+
if (typeof by !== 'string' || !by) return;
|
|
894
|
+
this.state.lastAction = { kind, by, at: new Date().toISOString() };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** The audit line of a human action (`kind` = stop | pause | resume): "<text> by <name>."
|
|
898
|
+
* when state.lastAction names who did it (actor stored always), "<text>." otherwise. */
|
|
899
|
+
async _auditAction(kind, text) {
|
|
900
|
+
const la = this.state.lastAction;
|
|
901
|
+
const actor = la && la.kind === kind && typeof la.by === 'string' ? la.by : null;
|
|
902
|
+
await appendAudit(this.pipeline.dir, `${text}${byActor(actor)}.`, { actor });
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** Who answered question `id` (identity.mjs actor), recorded by answer(). */
|
|
906
|
+
answeredBy(id) {
|
|
907
|
+
return this._answeredBy.get(id) ?? null;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** Audit a human decision on a gate / recovery / workflow proposal (answer()). Question
|
|
911
|
+
* and form answers are audited where they are written (with " by <name>"). */
|
|
912
|
+
_auditDecision(pq, value, by) {
|
|
913
|
+
if (!this.pipeline || !pq) return;
|
|
914
|
+
const d = value && typeof value === 'object' && typeof value.decision === 'string' ? value.decision : null;
|
|
915
|
+
let line = null;
|
|
916
|
+
if (pq.kind === 'workflow') line = `Workflow proposal **${d === 'decline' ? 'declined' : d || 'answered'}**`;
|
|
917
|
+
else if (pq.kind === 'gate') line = `Gate **${d || 'answered'}**`;
|
|
918
|
+
else if (pq.kind === 'recovery') line = `Recovery decision **${d || 'answered'}**`;
|
|
919
|
+
if (!line) return;
|
|
920
|
+
appendAudit(this.pipeline.dir, `${line}${byActor(by)}.`, { actor: by }).catch(() => {});
|
|
921
|
+
}
|
|
922
|
+
|
|
876
923
|
_checkPause() {
|
|
877
924
|
if (this.pauseRequested) throw pauseErr();
|
|
878
925
|
}
|
|
@@ -1028,6 +1075,7 @@ export class RunHarness extends EventEmitter {
|
|
|
1028
1075
|
extras: this.opts.extras,
|
|
1029
1076
|
title: this.opts.title,
|
|
1030
1077
|
guardrailsId: this.guardrailsId,
|
|
1078
|
+
startedBy: this.opts.startedBy || null,
|
|
1031
1079
|
...(this.isWorkspace ? {
|
|
1032
1080
|
workspaceKey: this.workspaceKey,
|
|
1033
1081
|
workspaceId: this.workspace.id,
|
|
@@ -1052,6 +1100,8 @@ export class RunHarness extends EventEmitter {
|
|
|
1052
1100
|
// guardrails_id and the curated UPSERT excludes it (creation-immutable), so
|
|
1053
1101
|
// mirroring it onto the live state only keeps rowToState round-trips honest.
|
|
1054
1102
|
this.state.guardrailsId = this.guardrailsId;
|
|
1103
|
+
// Who started it (identity.mjs): creation-immutable too, mirrored for the same reason.
|
|
1104
|
+
this.state.startedBy = this.opts.startedBy || null;
|
|
1055
1105
|
// Workspace: mirror the §5.2 superset onto the live state and FREEZE the
|
|
1056
1106
|
// description now (read from the pipeline's frozen state.json snapshot, never
|
|
1057
1107
|
// re-read from workspaces.json), so later registry edits never alter this run.
|
|
@@ -1245,7 +1295,7 @@ export class RunHarness extends EventEmitter {
|
|
|
1245
1295
|
this.state.resumePoint = null;
|
|
1246
1296
|
if (this.pipeline) {
|
|
1247
1297
|
await this._persist().catch(() => {});
|
|
1248
|
-
await
|
|
1298
|
+
await this._auditAction('stop', 'Pipeline **stopped**').catch(() => {});
|
|
1249
1299
|
// The diff artifact must survive a non-done terminal path too: the work done
|
|
1250
1300
|
// up to this point IS committed onto the kept feature branch by the teardown
|
|
1251
1301
|
// in the finally below, so History has to be able to show it. Safe HERE and
|
|
@@ -1389,6 +1439,8 @@ export class RunHarness extends EventEmitter {
|
|
|
1389
1439
|
};
|
|
1390
1440
|
// The saved point carries the pause that produced it; a resumed run is running.
|
|
1391
1441
|
this._clearPauseReason();
|
|
1442
|
+
this.state.lastAction = null;
|
|
1443
|
+
this._recordAction('resume', this.opts.resumedBy || null);
|
|
1392
1444
|
// Rehydrate the run's selection BEFORE re-resolving so resume enforces the
|
|
1393
1445
|
// LATEST saved set definition (missing set -> warn + Permissive, inside
|
|
1394
1446
|
// _resolveGuardrails). Legacy resume points without the field fall back to
|
|
@@ -1502,7 +1554,7 @@ export class RunHarness extends EventEmitter {
|
|
|
1502
1554
|
this._setStatus('running');
|
|
1503
1555
|
await this._persist();
|
|
1504
1556
|
this._startHeartbeat();
|
|
1505
|
-
await
|
|
1557
|
+
await this._auditAction('resume', String(rehydrated.audit || '').replace(/\.\s*$/, ''));
|
|
1506
1558
|
this._emit('state', this.getState());
|
|
1507
1559
|
this._rehydrated = true;
|
|
1508
1560
|
// The pause that parked this run is over; it must not colour a later failure
|
|
@@ -1596,7 +1648,7 @@ export class RunHarness extends EventEmitter {
|
|
|
1596
1648
|
this.state.resumePoint = null;
|
|
1597
1649
|
if (this.pipeline) {
|
|
1598
1650
|
await this._persist().catch(() => {});
|
|
1599
|
-
await
|
|
1651
|
+
await this._auditAction('stop', 'Pipeline **stopped**').catch(() => {});
|
|
1600
1652
|
// The diff artifact must survive a non-done terminal path too: the work done
|
|
1601
1653
|
// up to this point IS committed onto the kept feature branch by the teardown
|
|
1602
1654
|
// in the finally below, so History has to be able to show it. Safe HERE and
|
|
@@ -3675,6 +3727,12 @@ export class RunHarness extends EventEmitter {
|
|
|
3675
3727
|
return prompts;
|
|
3676
3728
|
}
|
|
3677
3729
|
|
|
3730
|
+
/** The most recent answerer (single-prompt flows that do not know the question id). */
|
|
3731
|
+
_lastAnsweredBy() {
|
|
3732
|
+
const all = [...this._answeredBy.values()];
|
|
3733
|
+
return all.length ? all[all.length - 1] : null;
|
|
3734
|
+
}
|
|
3735
|
+
|
|
3678
3736
|
async _writeClarifyAnswers(questions, answers) {
|
|
3679
3737
|
// M1: clarify answers live ONLY in the clarify DB row (the authoritative store).
|
|
3680
3738
|
// The dead FS clarify-answers.json (never read back; the single-round loop passes
|
|
@@ -3686,7 +3744,8 @@ export class RunHarness extends EventEmitter {
|
|
|
3686
3744
|
question: byId.get(a.id)?.question || '',
|
|
3687
3745
|
choice: a.choice,
|
|
3688
3746
|
}));
|
|
3689
|
-
|
|
3747
|
+
const by = this._lastAnsweredBy();
|
|
3748
|
+
await writeClarify(this.pipeline.id, { answers: { answers: enriched, ...(by ? { answeredBy: by } : {}) } });
|
|
3690
3749
|
return enriched;
|
|
3691
3750
|
}
|
|
3692
3751
|
|
|
@@ -4495,11 +4554,14 @@ export class RunHarness extends EventEmitter {
|
|
|
4495
4554
|
rp.titleProvisional = this.state.titleProvisional === true;
|
|
4496
4555
|
}
|
|
4497
4556
|
rp.interventions = { ...this._metricsIv };
|
|
4557
|
+
// Who paused it survives a restart (rowToState reads it back).
|
|
4558
|
+
if (this.state.lastAction && this.state.lastAction.kind === 'pause') rp.lastAction = { ...this.state.lastAction };
|
|
4559
|
+
else delete rp.lastAction;
|
|
4498
4560
|
}
|
|
4499
4561
|
this._setStatus('paused');
|
|
4500
4562
|
await this._persist();
|
|
4501
4563
|
// A plain manual pause has no reason; every reasoned pause audited at its site.
|
|
4502
|
-
if (!this.pauseReason) await
|
|
4564
|
+
if (!this.pauseReason) await this._auditAction('pause', 'Pipeline **paused**').catch(() => {});
|
|
4503
4565
|
// A FORCED pause (pauseReason set: usage limit, cost cap, auto-mode
|
|
4504
4566
|
// auth/quota, exhausted recoverable retries, an error) parks the run with
|
|
4505
4567
|
// nobody attached, so the task source must hear it NOW — statusToResult
|
package/src/core/scheduler.mjs
CHANGED
|
@@ -125,6 +125,9 @@ function rowToTicket(r, { withRequest = false } = {}) {
|
|
|
125
125
|
askCardId: r.ask_card_id || null,
|
|
126
126
|
createdAt: r.created_at,
|
|
127
127
|
updatedAt: r.updated_at,
|
|
128
|
+
// v39: who made it / last changed it (identity.mjs actor; 'local' allowed, null = before v39).
|
|
129
|
+
createdBy: r.created_by || null,
|
|
130
|
+
updatedBy: r.updated_by || null,
|
|
128
131
|
summary: summarizeRequest(request),
|
|
129
132
|
};
|
|
130
133
|
if (withRequest) t.request = request;
|
|
@@ -159,6 +162,8 @@ function rowToSchedule(r, { withRequest = false } = {}) {
|
|
|
159
162
|
askCardId: r.ask_card_id || null,
|
|
160
163
|
createdAt: r.created_at,
|
|
161
164
|
updatedAt: r.updated_at,
|
|
165
|
+
createdBy: r.created_by || null,
|
|
166
|
+
updatedBy: r.updated_by || null,
|
|
162
167
|
summary: summarizeRequest(request),
|
|
163
168
|
};
|
|
164
169
|
if (withRequest) s.request = request;
|
|
@@ -212,6 +217,7 @@ export function createTicket({
|
|
|
212
217
|
runAtMs, request, ifMissed = 'run', graceMin = 360, ownerPid = null, ownerHost = null,
|
|
213
218
|
askThreadId = null, askCardId = null, forced = false,
|
|
214
219
|
after = null, afterPolicy = 'done', sourceFromPrevious = false,
|
|
220
|
+
createdBy = null,
|
|
215
221
|
now = Date.now(),
|
|
216
222
|
}) {
|
|
217
223
|
const chained = normAfter(after);
|
|
@@ -222,12 +228,12 @@ export function createTicket({
|
|
|
222
228
|
getDb().prepare(`
|
|
223
229
|
INSERT INTO scheduled_runs (id, schedule_id, title, project_key, project_dir, workspace_id, run_at, request,
|
|
224
230
|
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', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
231
|
+
after_kind, after_id, after_policy, source_from_previous, created_at, updated_at, created_by, updated_by)
|
|
232
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'scheduled', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
227
233
|
`).run(id, scheduleId, title, tc.project_key, tc.project_dir, tc.workspace_id, chained ? AFTER_RUN_AT : iso(runAtMs), JSON.stringify(request || {}),
|
|
228
234
|
normPolicy(ifMissed, MISSED_POLICIES, 'run'), normGrace(graceMin), ownerPid, ownerPid != null ? (ownerHost || hostname()) : null,
|
|
229
235
|
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);
|
|
236
|
+
chained ? chained.kind : null, chained ? chained.id : null, normPolicy(afterPolicy, AFTER_POLICIES, 'done'), chained && sourceFromPrevious ? 1 : 0, ts, ts, byOf(createdBy), byOf(createdBy));
|
|
231
237
|
return getTicket(id);
|
|
232
238
|
}
|
|
233
239
|
|
|
@@ -251,16 +257,23 @@ export function listTickets({ projectDir = null, workspaceId = null, scheduleId
|
|
|
251
257
|
return getDb().prepare(sql).all(...args).map((r) => rowToTicket(r));
|
|
252
258
|
}
|
|
253
259
|
|
|
254
|
-
function touchTicket(id, sets, args, now) {
|
|
260
|
+
function touchTicket(id, sets, args, now, by = undefined) {
|
|
261
|
+
if (by !== undefined && byOf(by)) return getDb().prepare(`UPDATE scheduled_runs SET ${sets}, updated_at = ?, updated_by = ? WHERE id = ?`).run(...args, iso(now), byOf(by), id).changes;
|
|
255
262
|
return getDb().prepare(`UPDATE scheduled_runs SET ${sets}, updated_at = ? WHERE id = ?`).run(...args, iso(now), id).changes;
|
|
256
263
|
}
|
|
257
264
|
|
|
265
|
+
/** A stored actor: a trimmed one-line string (identity.mjs values), else null. */
|
|
266
|
+
function byOf(by) {
|
|
267
|
+
const v = typeof by === 'string' ? by.trim() : '';
|
|
268
|
+
return v && v.length <= 200 && !/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(v) ? v : null;
|
|
269
|
+
}
|
|
270
|
+
|
|
258
271
|
/**
|
|
259
272
|
* Move a waiting (or missed) ticket to a new time and/or change its missed policy.
|
|
260
273
|
* A missed ticket becomes `scheduled` again and its alarm resolves.
|
|
261
274
|
* @returns {object|null} the ticket, or null when it is not editable
|
|
262
275
|
*/
|
|
263
|
-
export function updateTicket(id, { runAtMs, ifMissed, graceMin, after, afterPolicy, sourceFromPrevious } = {}, { now = Date.now() } = {}) {
|
|
276
|
+
export function updateTicket(id, { runAtMs, ifMissed, graceMin, after, afterPolicy, sourceFromPrevious } = {}, { now = Date.now(), by = undefined } = {}) {
|
|
264
277
|
const t = getTicket(id);
|
|
265
278
|
if (!t || !['scheduled', 'missed'].includes(t.status)) return null;
|
|
266
279
|
const sets = [];
|
|
@@ -289,7 +302,7 @@ export function updateTicket(id, { runAtMs, ifMissed, graceMin, after, afterPoli
|
|
|
289
302
|
if (ifMissed !== undefined) { sets.push('if_missed = ?'); args.push(normPolicy(ifMissed, MISSED_POLICIES, t.ifMissed)); }
|
|
290
303
|
if (graceMin !== undefined) { sets.push('grace_min = ?'); args.push(normGrace(graceMin, t.graceMin)); }
|
|
291
304
|
if (!sets.length) return t;
|
|
292
|
-
touchTicket(id, sets.join(', '), args, now);
|
|
305
|
+
touchTicket(id, sets.join(', '), args, now, by);
|
|
293
306
|
if (Number.isFinite(runAtMs) || chained) resolveNotifications({ ticketId: id, kinds: ['missed', 'failed'] });
|
|
294
307
|
return getTicket(id);
|
|
295
308
|
}
|
|
@@ -298,9 +311,9 @@ export function updateTicket(id, { runAtMs, ifMissed, graceMin, after, afterPoli
|
|
|
298
311
|
* Cancel a one-shot ticket (scheduled or missed). For an occurrence of a series use
|
|
299
312
|
* skipNext(). @returns {object|null}
|
|
300
313
|
*/
|
|
301
|
-
export function cancelTicket(id, { now = Date.now() } = {}) {
|
|
302
|
-
const changed = getDb().prepare("UPDATE scheduled_runs SET status = 'canceled', updated_at =
|
|
303
|
-
.run(iso(now), String(id)).changes;
|
|
314
|
+
export function cancelTicket(id, { now = Date.now(), by = undefined } = {}) {
|
|
315
|
+
const changed = getDb().prepare("UPDATE scheduled_runs SET status = 'canceled', updated_at = ?, updated_by = COALESCE(?, updated_by) WHERE id = ? AND status IN ('scheduled', 'missed')")
|
|
316
|
+
.run(iso(now), byOf(by), String(id)).changes;
|
|
304
317
|
if (!changed) return null;
|
|
305
318
|
const t = getTicket(id);
|
|
306
319
|
resolveNotifications({ ticketId: id });
|
|
@@ -312,9 +325,10 @@ export function cancelTicket(id, { now = Date.now() } = {}) {
|
|
|
312
325
|
* Ask for an immediate start: the ticket becomes due now and bypasses the missed and
|
|
313
326
|
* overlap checks. The host's tick (or the owning `--wait` CLI) picks it up.
|
|
314
327
|
*/
|
|
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
|
-
|
|
328
|
+
export function requestRunNow(id, { now = Date.now(), by = undefined } = {}) {
|
|
329
|
+
const changed = getDb().prepare(`UPDATE scheduled_runs SET status = 'scheduled', forced = 1, retry_at = NULL, queued = 0, fail_reason = NULL, updated_at = ?,
|
|
330
|
+
updated_by = COALESCE(?, updated_by)
|
|
331
|
+
WHERE id = ? AND status IN ('scheduled', 'missed')`).run(iso(now), byOf(by), String(id)).changes;
|
|
318
332
|
if (!changed) return null;
|
|
319
333
|
resolveNotifications({ ticketId: id, kinds: ['missed', 'failed'] });
|
|
320
334
|
return getTicket(id);
|
|
@@ -541,7 +555,7 @@ export function resolveAfterRef(after, { projectDir = null, workspaceId = null,
|
|
|
541
555
|
export function createSchedule({
|
|
542
556
|
title = null, projectDir = null, workspaceId = null, request, rule, overlap = 'skip',
|
|
543
557
|
maxFailures = 3, ifMissed = 'run', graceMin = 360, id = `sch_${randomBytes(4).toString('hex')}`,
|
|
544
|
-
askThreadId = null, askCardId = null, now = Date.now(),
|
|
558
|
+
askThreadId = null, askCardId = null, createdBy = null, now = Date.now(),
|
|
545
559
|
}) {
|
|
546
560
|
const norm = normalizeRule(rule, { todayLocal: rule?.anchor || null });
|
|
547
561
|
if (!norm.ok) throw new Error(norm.error);
|
|
@@ -550,11 +564,11 @@ export function createSchedule({
|
|
|
550
564
|
tx(() => {
|
|
551
565
|
getDb().prepare(`
|
|
552
566
|
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', ?, ?, ?, ?)
|
|
567
|
+
if_missed, grace_min, status, ask_thread_id, ask_card_id, created_at, updated_at, created_by, updated_by)
|
|
568
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)
|
|
555
569
|
`).run(id, title, tc.project_key, tc.project_dir, tc.workspace_id, JSON.stringify(request || {}), JSON.stringify(norm.rule),
|
|
556
570
|
normPolicy(overlap, OVERLAP_POLICIES, 'skip'), normMaxFailures(maxFailures), normPolicy(ifMissed, MISSED_POLICIES, 'run'),
|
|
557
|
-
normGrace(graceMin), askThreadId, askCardId, ts, ts);
|
|
571
|
+
normGrace(graceMin), askThreadId, askCardId, ts, ts, byOf(createdBy), byOf(createdBy));
|
|
558
572
|
});
|
|
559
573
|
const ticket = materializeNext(id, { now });
|
|
560
574
|
return { schedule: getSchedule(id), ticket };
|
|
@@ -599,7 +613,7 @@ export function materializeNext(scheduleId, { now = Date.now() } = {}) {
|
|
|
599
613
|
}
|
|
600
614
|
const ticket = createTicket({
|
|
601
615
|
scheduleId, title: s.title, projectDir: s.projectDir, workspaceId: s.workspaceId, runAtMs: next,
|
|
602
|
-
request: s.request, ifMissed: s.ifMissed, graceMin: s.graceMin, now,
|
|
616
|
+
request: s.request, ifMissed: s.ifMissed, graceMin: s.graceMin, createdBy: s.createdBy, now,
|
|
603
617
|
});
|
|
604
618
|
getDb().prepare('UPDATE schedules SET next_run_at = ?, updated_at = ? WHERE id = ?').run(iso(next), iso(now), scheduleId);
|
|
605
619
|
return ticket;
|
|
@@ -610,7 +624,7 @@ export function materializeNext(scheduleId, { now = Date.now() } = {}) {
|
|
|
610
624
|
* rule; a run that already started is untouched.
|
|
611
625
|
* @throws {Error} on an invalid rule
|
|
612
626
|
*/
|
|
613
|
-
export function updateSchedule(id, patch = {}, { now = Date.now() } = {}) {
|
|
627
|
+
export function updateSchedule(id, patch = {}, { now = Date.now(), by = undefined } = {}) {
|
|
614
628
|
const s = getSchedule(id, { withRequest: true });
|
|
615
629
|
if (!s) return null;
|
|
616
630
|
const sets = [];
|
|
@@ -634,7 +648,7 @@ export function updateSchedule(id, patch = {}, { now = Date.now() } = {}) {
|
|
|
634
648
|
if (patch.request !== undefined) { sets.push('request = ?'); args.push(JSON.stringify(patch.request || {})); }
|
|
635
649
|
if (!sets.length) return s;
|
|
636
650
|
tx(() => {
|
|
637
|
-
getDb().prepare(`UPDATE schedules SET ${sets.join(', ')}, updated_at =
|
|
651
|
+
getDb().prepare(`UPDATE schedules SET ${sets.join(', ')}, updated_at = ?, updated_by = COALESCE(?, updated_by) WHERE id = ?`).run(...args, iso(now), byOf(by), id);
|
|
638
652
|
dropPendingTickets(id);
|
|
639
653
|
});
|
|
640
654
|
materializeNext(id, { now });
|
|
@@ -642,18 +656,18 @@ export function updateSchedule(id, patch = {}, { now = Date.now() } = {}) {
|
|
|
642
656
|
}
|
|
643
657
|
|
|
644
658
|
/** 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 =
|
|
647
|
-
.run(reason, iso(now), String(id)).changes;
|
|
659
|
+
export function pauseSchedule(id, { reason = 'user', now = Date.now(), by = undefined } = {}) {
|
|
660
|
+
const changed = getDb().prepare("UPDATE schedules SET status = 'paused', pause_reason = ?, next_run_at = NULL, updated_at = ?, updated_by = COALESCE(?, updated_by) WHERE id = ? AND status = 'active'")
|
|
661
|
+
.run(reason, iso(now), byOf(by), String(id)).changes;
|
|
648
662
|
if (!changed) return null;
|
|
649
663
|
dropPendingTickets(id);
|
|
650
664
|
return getSchedule(id);
|
|
651
665
|
}
|
|
652
666
|
|
|
653
667
|
/** 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 =
|
|
656
|
-
.run(iso(now), String(id)).changes;
|
|
668
|
+
export function resumeSchedule(id, { now = Date.now(), by = undefined } = {}) {
|
|
669
|
+
const changed = getDb().prepare("UPDATE schedules SET status = 'active', pause_reason = NULL, failure_streak = 0, updated_at = ?, updated_by = COALESCE(?, updated_by) WHERE id = ? AND status = 'paused'")
|
|
670
|
+
.run(iso(now), byOf(by), String(id)).changes;
|
|
657
671
|
if (!changed) return null;
|
|
658
672
|
resolveNotifications({ scheduleId: id });
|
|
659
673
|
materializeNext(id, { now });
|
|
@@ -661,23 +675,24 @@ export function resumeSchedule(id, { now = Date.now() } = {}) {
|
|
|
661
675
|
}
|
|
662
676
|
|
|
663
677
|
/** Skip the pending occurrence of a series and materialise the one after it. */
|
|
664
|
-
export function skipNext(id, { now = Date.now() } = {}) {
|
|
678
|
+
export function skipNext(id, { now = Date.now(), by = undefined } = {}) {
|
|
665
679
|
const s = getSchedule(id);
|
|
666
680
|
if (!s || s.status !== 'active') return null;
|
|
667
681
|
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
682
|
if (!row) return s;
|
|
669
|
-
touchTicket(row.id, "status = 'skipped', fail_reason = 'skipped by user'", [], now);
|
|
683
|
+
touchTicket(row.id, "status = 'skipped', fail_reason = 'skipped by user'", [], now, by);
|
|
684
|
+
if (byOf(by)) getDb().prepare('UPDATE schedules SET updated_at = ?, updated_by = ? WHERE id = ?').run(iso(now), byOf(by), id);
|
|
670
685
|
materializeNext(id, { now: Math.max(now, Date.parse(row.run_at)) });
|
|
671
686
|
return getSchedule(id);
|
|
672
687
|
}
|
|
673
688
|
|
|
674
689
|
/** Start one extra occurrence now; the series itself does not shift. */
|
|
675
|
-
export function runScheduleNow(id, { now = Date.now() } = {}) {
|
|
690
|
+
export function runScheduleNow(id, { now = Date.now(), by = undefined } = {}) {
|
|
676
691
|
const s = getSchedule(id, { withRequest: true });
|
|
677
692
|
if (!s || s.status === 'ended') return null;
|
|
678
693
|
return createTicket({
|
|
679
694
|
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,
|
|
695
|
+
request: s.request, ifMissed: 'run', graceMin: s.graceMin, forced: true, createdBy: byOf(by) || s.createdBy, now,
|
|
681
696
|
});
|
|
682
697
|
}
|
|
683
698
|
|
|
@@ -708,6 +723,21 @@ export function dependentsOfWorkflow(workflowId) {
|
|
|
708
723
|
return out;
|
|
709
724
|
}
|
|
710
725
|
|
|
726
|
+
// ── who last changed it (notification text) ─────────────────────────────────
|
|
727
|
+
|
|
728
|
+
/** " Last changed by X." for a ticket/series whose last actor is a person, else ''.
|
|
729
|
+
* The series' actor wins over its occurrence ticket's (the ticket inherits it). */
|
|
730
|
+
export function lastChangedSuffix(t, schedule = null) {
|
|
731
|
+
const who = byOf(schedule?.updatedBy || schedule?.createdBy || t?.updatedBy || t?.createdBy || null);
|
|
732
|
+
return who && who !== 'local' ? ` Last changed by ${who}.` : '';
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** The suffix after a free-text reason: nothing locally; else a closing period if needed + the suffix. */
|
|
736
|
+
function withLastChanged(reason, t, schedule) {
|
|
737
|
+
const suffix = lastChangedSuffix(t, schedule);
|
|
738
|
+
return suffix ? `${/[.!?]$/.test(String(reason)) ? '' : '.'}${suffix}` : '';
|
|
739
|
+
}
|
|
740
|
+
|
|
711
741
|
// ── failure streak ───────────────────────────────────────────────────────────
|
|
712
742
|
|
|
713
743
|
function bumpFailure(scheduleId, now) {
|
|
@@ -719,7 +749,7 @@ function bumpFailure(scheduleId, now) {
|
|
|
719
749
|
pauseSchedule(scheduleId, { reason: 'failure_streak', now });
|
|
720
750
|
addNotification({
|
|
721
751
|
kind: 'paused', scheduleId, projectDir: s.projectDir, title: s.title,
|
|
722
|
-
message: `paused itself after ${streak} failure${streak === 1 ? '' : 's'} in a row
|
|
752
|
+
message: `paused itself after ${streak} failure${streak === 1 ? '' : 's'} in a row.${lastChangedSuffix(null, s)}`, now: new Date(now),
|
|
723
753
|
});
|
|
724
754
|
}
|
|
725
755
|
}
|
|
@@ -764,7 +794,7 @@ function miss(t, schedule, now, why) {
|
|
|
764
794
|
touchTicket(t.id, "status = 'missed', fail_reason = ?", [why], now);
|
|
765
795
|
addNotification({
|
|
766
796
|
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),
|
|
797
|
+
message: `was due at ${t.runAt}. ${why}${lastChangedSuffix(t, schedule)}`, now: new Date(now),
|
|
768
798
|
});
|
|
769
799
|
if (schedule) { setLastResult(schedule.id, 'missed', now); bumpFailure(schedule.id, now); materializeNext(schedule.id, { now }); }
|
|
770
800
|
}
|
|
@@ -774,7 +804,7 @@ function missAfter(t, now, p) {
|
|
|
774
804
|
touchTicket(t.id, "status = 'missed', fail_reason = ?", [`The run before it ${p.reason}.`], now);
|
|
775
805
|
addNotification({
|
|
776
806
|
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}
|
|
807
|
+
message: `was waiting for ‘${p.title || 'the run before it'}’, which ${p.reason}.${lastChangedSuffix(t)}`, now: new Date(now),
|
|
778
808
|
});
|
|
779
809
|
}
|
|
780
810
|
|
|
@@ -890,7 +920,7 @@ export async function runDueTickets({
|
|
|
890
920
|
touchTicket(t.id, "status = 'failed', fail_reason = ?, forced = 0", [reason], now);
|
|
891
921
|
addNotification({
|
|
892
922
|
kind: 'failed', scheduleId: t.scheduleId, ticketId: t.id, projectDir: t.projectDir, title: t.title,
|
|
893
|
-
message: `could not start: ${reason}`, now: new Date(now),
|
|
923
|
+
message: `could not start: ${reason}${withLastChanged(reason, t, schedule)}`, now: new Date(now),
|
|
894
924
|
});
|
|
895
925
|
if (schedule) { setLastResult(schedule.id, 'failed', now); bumpFailure(schedule.id, now); materializeNext(schedule.id, { now }); }
|
|
896
926
|
else removeStage(t.id);
|