@worca/app 1.4.0 → 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.
Files changed (80) hide show
  1. package/README.md +36 -1
  2. package/docker/.env.example +54 -0
  3. package/docker/compose.clonein.yml +24 -0
  4. package/docker/compose.dev.yml +24 -0
  5. package/docker/compose.egress.yml +47 -0
  6. package/docker/compose.ssh.yml +17 -0
  7. package/docker/compose.teams.yml +25 -0
  8. package/docker/compose.yml +57 -0
  9. package/package.json +4 -1
  10. package/src/cli/container.mjs +227 -0
  11. package/src/cli/models.mjs +5 -4
  12. package/src/cli/schedule.mjs +23 -10
  13. package/src/cli/worca-cc.mjs +62 -4
  14. package/src/core/agent-user.mjs +92 -0
  15. package/src/core/artifacts.mjs +41 -9
  16. package/src/core/ask/clone-deps.mjs +25 -0
  17. package/src/core/ask/clone-proposal.mjs +79 -0
  18. package/src/core/ask/events.mjs +10 -0
  19. package/src/core/ask/mcp-stdio.mjs +4 -2
  20. package/src/core/ask/model-proposal.mjs +14 -3
  21. package/src/core/ask/prompt.mjs +27 -6
  22. package/src/core/ask/schedule-deps.mjs +16 -11
  23. package/src/core/ask/spawn.mjs +8 -3
  24. package/src/core/ask/store.mjs +40 -10
  25. package/src/core/ask/tool-deps.mjs +36 -1
  26. package/src/core/ask/tools.mjs +69 -8
  27. package/src/core/ask/turn.mjs +31 -1
  28. package/src/core/bridge/errors.mjs +50 -2
  29. package/src/core/bridge/provider-ops.mjs +20 -7
  30. package/src/core/bridge/providers/copilot.mjs +51 -5
  31. package/src/core/bridge/translate/common.mjs +144 -0
  32. package/src/core/bridge/translate/request.mjs +16 -73
  33. package/src/core/bridge/translate/responses-request.mjs +185 -0
  34. package/src/core/bridge/translate/responses-stream.mjs +327 -0
  35. package/src/core/bridge/upstream.mjs +37 -10
  36. package/src/core/cf-access.mjs +90 -0
  37. package/src/core/chat/command-router.mjs +17 -12
  38. package/src/core/claude-runner.mjs +26 -3
  39. package/src/core/clone-project.mjs +139 -0
  40. package/src/core/config.mjs +16 -9
  41. package/src/core/db.mjs +60 -5
  42. package/src/core/deployment.mjs +33 -0
  43. package/src/core/diff-comments.mjs +17 -8
  44. package/src/core/git-info.mjs +27 -7
  45. package/src/core/github-app.mjs +107 -0
  46. package/src/core/github-credentials.mjs +93 -0
  47. package/src/core/graph/executor.mjs +22 -4
  48. package/src/core/graph/script-runner.mjs +24 -3
  49. package/src/core/guardrails.mjs +1 -0
  50. package/src/core/identity.mjs +83 -0
  51. package/src/core/metrics/pr-events-workflow.yml +127 -0
  52. package/src/core/metrics/prs.mjs +383 -0
  53. package/src/core/metrics/read.mjs +39 -29
  54. package/src/core/metrics/record.mjs +30 -2
  55. package/src/core/metrics/sync.mjs +35 -4
  56. package/src/core/model-env.mjs +47 -7
  57. package/src/core/model-test.mjs +29 -5
  58. package/src/core/notifications.mjs +36 -14
  59. package/src/core/orchestrator.mjs +10 -4
  60. package/src/core/phases.mjs +2 -0
  61. package/src/core/pipeline-delete.mjs +4 -3
  62. package/src/core/plugin-repo.mjs +7 -3
  63. package/src/core/policy/gate.mjs +9 -5
  64. package/src/core/policy/state.mjs +2 -2
  65. package/src/core/policy/sync.mjs +5 -4
  66. package/src/core/remote-access.mjs +138 -0
  67. package/src/core/run-harness.mjs +71 -9
  68. package/src/core/scheduler.mjs +63 -33
  69. package/src/shared/team-metrics/timeline.mjs +330 -0
  70. package/ui/public/app.js +591 -14
  71. package/ui/public/ask-panel.mjs +65 -3
  72. package/ui/public/bridge-view.mjs +72 -14
  73. package/ui/public/index.html +42 -9
  74. package/ui/public/schedules-view.mjs +21 -2
  75. package/ui/public/session-guard.mjs +77 -0
  76. package/ui/public/style.css +171 -1
  77. package/ui/public/team-metrics-timeline.mjs +480 -0
  78. package/ui/public/team-metrics-view.mjs +10 -2
  79. package/ui/public/team-policy-view.mjs +6 -1
  80. package/ui/server.mjs +454 -104
@@ -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
- stop() {
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 appendAudit(this.pipeline.dir, `Pipeline **stopped**.`).catch(() => {});
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 appendAudit(this.pipeline.dir, rehydrated.audit);
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 appendAudit(this.pipeline.dir, `Pipeline **stopped**.`).catch(() => {});
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
- await writeClarify(this.pipeline.id, { answers: { answers: enriched } });
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 appendAudit(this.pipeline.dir, `Pipeline **paused**.`).catch(() => {});
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
@@ -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 = ? WHERE id = ? AND status IN ('scheduled', 'missed')")
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
- WHERE id = ? AND status IN ('scheduled', 'missed')`).run(iso(now), String(id)).changes;
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 = ? WHERE id = ?`).run(...args, iso(now), id);
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 = ? WHERE id = ? AND status = 'active'")
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 = ? WHERE id = ? AND status = 'paused'")
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.`, now: new Date(now),
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}.`, now: new Date(now),
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);