clay-server 4.1.0-beta.10 → 4.1.0-beta.12

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.
@@ -38,6 +38,7 @@ function attachLoopRegistryOccurrence(ctx) {
38
38
  ctx.scheduledRuns.prepareRecord(rec, prepared);
39
39
  if (!ctx.save()) { recordRevision.restoreRecord(rec, previous); return { ok: false, error: ctx.lastSaveError() || "The occurrence reservation could not be stored." }; }
40
40
  ctx.changed();
41
+ if (prepared && prepared.skippedRun) ctx.scheduledRuns.notifyTerminal(rec, prepared.skippedRun);
41
42
  return { ok: true, value: prepared };
42
43
  }
43
44
 
@@ -77,6 +77,7 @@ function attachConnection(ctx) {
77
77
  var _loop = ctx._loop;
78
78
  var _mcp = ctx._mcp;
79
79
  var _notifications = ctx._notifications;
80
+ var _projectLogs = ctx._projectLogs;
80
81
  var _splitGroups = ctx._splitGroups;
81
82
  var _vendorLogin = ctx._vendorLogin;
82
83
  var hydrateImageRefs = ctx.hydrateImageRefs;
@@ -229,6 +230,7 @@ function attachConnection(ctx) {
229
230
  _loop.sendConnectionState(ws);
230
231
  if (_mcp) _mcp.sendConnectionState(ws);
231
232
  if (_notifications) _notifications.sendConnectionState(ws, sendTo);
233
+ if (_projectLogs) _projectLogs.sendScheduledResultState(ws);
232
234
 
233
235
  // Session list (filtered for access control). Uses the same mapper as
234
236
  // broadcastSessionList: a hand-rolled copy here silently drifts and
@@ -196,7 +196,7 @@ function cleanLine(value, max) {
196
196
 
197
197
  function normalizeAuthor(author) {
198
198
  var source = author || {};
199
- var type = source.type === "user" ? "user" : "session";
199
+ var type = source.type === "user" ? "user" : (source.type === "system" ? "system" : "session");
200
200
  return {
201
201
  type: type,
202
202
  userId: source.userId ? cleanLine(String(source.userId), 120) : null,
@@ -253,6 +253,11 @@ function attachProjectLogsService(ctx) {
253
253
 
254
254
  function createProjectBound(initial, revalidate) {
255
255
  var store = storeForStatus(initial.status);
256
+ var scheduledResultViewerId = initial.principal.userId || null;
257
+
258
+ function withScheduledResultViewer(args) {
259
+ return Object.assign({}, args || {}, { scheduledResultOwnerId: scheduledResultViewerId });
260
+ }
256
261
 
257
262
  // Identity, authorization, and the target store are all re-derived per
258
263
  // call. revalidate() re-checks exact session identity where there is one;
@@ -288,7 +293,15 @@ function attachProjectLogsService(ctx) {
288
293
 
289
294
  function scopedEntry(args) {
290
295
  var scope = currentScope();
291
- return visibleEntry(live(), args && args.ref, scope.status, scope.worktreeOnly);
296
+ var entry = visibleEntry(live(), args && args.ref, scope.status, scope.worktreeOnly);
297
+ if (entry.scheduledResult && (entry.scheduledResult.ownerId || null) !== scheduledResultViewerId) throw new Error("Log entry not found.");
298
+ return entry;
299
+ }
300
+
301
+ function mutableEntry(args) {
302
+ var entry = scopedEntry(args);
303
+ if (entry.scheduledResult) throw new Error("Scheduled results are server-owned records.");
304
+ return entry;
292
305
  }
293
306
 
294
307
  function mutationContext() {
@@ -312,7 +325,7 @@ function attachProjectLogsService(ctx) {
312
325
  listLogs: function (args) {
313
326
  var scope = currentScope();
314
327
  var status = scope.status;
315
- var options = scopedArgs(args, status, scope.worktreeOnly);
328
+ var options = withScheduledResultViewer(scopedArgs(args, status, scope.worktreeOnly));
316
329
  var result = live().list(options);
317
330
  result.contextMode = options.contextMode;
318
331
  result.currentChangeSetId = status.changeSetId || null;
@@ -322,7 +335,7 @@ function attachProjectLogsService(ctx) {
322
335
  searchLogs: function (args) {
323
336
  var scope = currentScope();
324
337
  var status = scope.status;
325
- var options = scopedArgs(args, status, scope.worktreeOnly);
338
+ var options = withScheduledResultViewer(scopedArgs(args, status, scope.worktreeOnly));
326
339
  var result = live().search(options);
327
340
  result.contextMode = options.contextMode;
328
341
  result.currentChangeSetId = status.changeSetId || null;
@@ -334,18 +347,20 @@ function attachProjectLogsService(ctx) {
334
347
  },
335
348
  logHistory: function (args) { scopedEntry(args); return live().history(args && args.ref, args || {}); },
336
349
  commentLog: function (args) { scopedEntry(args); return live().comment(args && args.ref, args || {}, author(), mutationContext()); },
350
+ scheduledResultsState: function () { var scope = currentScope(); return live().unreadScheduledResults(scheduledResultViewerId, scopedArgs({}, scope.status, scope.worktreeOnly)); },
351
+ markScheduledResultRead: function (args) { var scope = currentScope(); scopedEntry(args); return live().markScheduledResultRead(args && args.ref, scheduledResultViewerId, scopedArgs({}, scope.status, scope.worktreeOnly)); },
337
352
  readLogRevision: function (args) { scopedEntry(args); return live().readRevision(args && args.ref, args && args.revision); },
338
353
  listLogFeedback: canonical ? function (args) {
339
354
  var scope = currentScope();
340
- return live().feedback(scopedArgs(args, scope.status, scope.worktreeOnly));
355
+ return live().feedback(withScheduledResultViewer(scopedArgs(args, scope.status, scope.worktreeOnly)));
341
356
  } : driverOnly,
342
- reviewLogComment: canonical ? function (args) { scopedEntry(args); return live().review(args && args.ref, args || {}, author(), mutationContext()); } : driverOnly,
357
+ reviewLogComment: canonical ? function (args) { if (args && args.action === "incorporate") mutableEntry(args); else scopedEntry(args); return live().review(args && args.ref, args || {}, author(), mutationContext()); } : driverOnly,
343
358
  revertLog: canonical
344
- ? function (args) { scopedEntry(args); return live().revert(args && args.ref, args && args.revision, args && args.reason, author(), mutationContext()); }
359
+ ? function (args) { mutableEntry(args); return live().revert(args && args.ref, args && args.revision, args && args.reason, author(), mutationContext()); }
345
360
  : driverOnly,
346
361
  createLog: canonical ? function (args) { return live().create(args || {}, author(), mutationContext()); } : agentOnly,
347
- updateLog: canonical ? function (args) { scopedEntry(args); return live().update(args && args.ref, args || {}, author(), mutationContext()); } : agentOnly,
348
- linkLog: canonical ? function (args) { scopedEntry(args); return live().link(args && args.ref, args && args.links, author(), mutationContext()); } : agentOnly,
362
+ updateLog: canonical ? function (args) { mutableEntry(args); return live().update(args && args.ref, args || {}, author(), mutationContext()); } : agentOnly,
363
+ linkLog: canonical ? function (args) { mutableEntry(args); return live().link(args && args.ref, args && args.links, author(), mutationContext()); } : agentOnly,
349
364
  removeLog: canDelete ? function (args) {
350
365
  var current = revalidate();
351
366
  var project = projectFor(current.principal.projectSlug);
@@ -353,7 +368,7 @@ function attachProjectLogsService(ctx) {
353
368
  if (!project || !authorizedProject(current.principal, currentStatus)) {
354
369
  throw new Error("Project Logs are not available for this project.");
355
370
  }
356
- visibleEntry(storeForStatus(currentStatus), args && args.ref, currentStatus, projectAuthorization(current.principal, currentStatus).worktreeOnly);
371
+ mutableEntry(args);
357
372
  if (current.principal.kind !== "session" && !ownsProject(current.principal, currentStatus)) ownerOnly();
358
373
  return storeForStatus(currentStatus).remove(args && args.ref, current.author, recordContext(currentStatus));
359
374
  } : ownerOnly,
@@ -367,6 +382,20 @@ function attachProjectLogsService(ctx) {
367
382
  return storeForStatus(projectStatus).setContextState(recordContext(projectStatus), status);
368
383
  }
369
384
 
385
+ // Trusted server lifecycle path. It is deliberately not part of any bound
386
+ // human or MCP write capability, so system results cannot be impersonated.
387
+ function recordScheduledResult(source) {
388
+ var data = source || {};
389
+ var project = projectFor(data.projectSlug);
390
+ if (!project) throw new Error("Project not found.");
391
+ var status = project.getStatus();
392
+ if (!status || status.isMate === true) throw new Error("Scheduled results require a project.");
393
+ if (isMultiUser()) {
394
+ if (!data.ownerId || !findUserById(data.ownerId)) throw new Error("Scheduled result owner is unavailable.");
395
+ }
396
+ return storeForStatus(status).createScheduledResult(data, recordContext(status));
397
+ }
398
+
370
399
  function createClayBound(revalidate) {
371
400
  function target(args) {
372
401
  var principal = revalidate();
@@ -374,14 +403,16 @@ function attachProjectLogsService(ctx) {
374
403
  if (!slug) throw new Error("An exact project slug is required.");
375
404
  var status = statusFor(principal, slug);
376
405
  var decision = projectAuthorization(principal, status);
377
- return { store: storeForStatus(status), status: status, worktreeOnly: decision.worktreeOnly };
406
+ return { store: storeForStatus(status), status: status, worktreeOnly: decision.worktreeOnly, viewerId: principal.userId || null };
378
407
  }
379
408
 
380
409
  function targetEntry(args) {
381
410
  var selected = target(args);
411
+ var entry = visibleEntry(selected.store, args && args.ref, selected.status, selected.worktreeOnly);
412
+ if (entry.scheduledResult && (entry.scheduledResult.ownerId || null) !== selected.viewerId) throw new Error("Log entry not found.");
382
413
  return {
383
414
  selected: selected,
384
- entry: visibleEntry(selected.store, args && args.ref, selected.status, selected.worktreeOnly),
415
+ entry: entry,
385
416
  };
386
417
  }
387
418
 
@@ -405,6 +436,7 @@ function attachProjectLogsService(ctx) {
405
436
  seenScopes.add(scopeKey);
406
437
  var entry = null;
407
438
  try { entry = visibleEntry(storeForStatus(status), ref, status, decision.worktreeOnly); } catch (e) { return; }
439
+ if (entry && entry.scheduledResult && (entry.scheduledResult.ownerId || null) !== (principal.userId || null)) return;
408
440
  if (!entry) return;
409
441
  var targetSlug = decision.worktreeOnly
410
442
  ? slug
@@ -425,11 +457,11 @@ function attachProjectLogsService(ctx) {
425
457
  author: null,
426
458
  listLogs: function (args) {
427
459
  var selected = target(args);
428
- return selected.store.list(scopedArgs(args, selected.status, selected.worktreeOnly));
460
+ return selected.store.list(Object.assign({}, scopedArgs(args, selected.status, selected.worktreeOnly), { scheduledResultOwnerId: selected.viewerId }));
429
461
  },
430
462
  searchLogs: function (args) {
431
463
  var selected = target(args);
432
- return selected.store.search(scopedArgs(args, selected.status, selected.worktreeOnly));
464
+ return selected.store.search(Object.assign({}, scopedArgs(args, selected.status, selected.worktreeOnly), { scheduledResultOwnerId: selected.viewerId }));
433
465
  },
434
466
  readLog: function (args) {
435
467
  return targetEntry(args).entry;
@@ -458,6 +490,7 @@ function attachProjectLogsService(ctx) {
458
490
  bindProjectSession: bindProjectSession,
459
491
  bindUser: bindUser,
460
492
  bindMate: bindMate,
493
+ recordScheduledResult: recordScheduledResult,
461
494
  setContextState: setContextState,
462
495
  };
463
496
  }
@@ -1,5 +1,4 @@
1
1
  // Project Logs projection over the Clay-wide knowledge record backend.
2
-
3
2
  var fs = require("fs");
4
3
  var path = require("path");
5
4
  var crypto = require("crypto");
@@ -12,6 +11,7 @@ var logsVersioning = require("./project-logs-versioning");
12
11
  var logsRoot = require("./project-logs-root");
13
12
  var logContext = require("./project-log-context");
14
13
  var contextState = require("./project-logs-context-state");
14
+ var scheduledResultStore = require("./project-scheduled-result-store");
15
15
 
16
16
  var MAX_SUMMARY_CHARS = logsSchema.MAX_SUMMARY_CHARS;
17
17
  var MAX_COMMENT_CHARS = 4000;
@@ -34,7 +34,6 @@ var normalizeTags = logsSchema.normalizeTags;
34
34
  var normalizeLinks = logsSchema.normalizeLinks;
35
35
 
36
36
  var page = logsQuery.page;
37
-
38
37
  // Filters accept any well-formed category. An unknown but valid one matches
39
38
  // nothing, which is a real answer. A supplied malformed one is an error:
40
39
  // silently dropping it would widen the result set to everything the caller is
@@ -45,7 +44,6 @@ function validateKind(kind, fallback) {
45
44
  return logsSchema.normalizeCategory(kind);
46
45
  }
47
46
 
48
-
49
47
  function createProjectLogsStore(opts) {
50
48
  var options = opts || {};
51
49
  var root = options.root ? path.resolve(options.root) : logsRoot.resolveProjectRoot(options.cwd, options.runGit);
@@ -116,6 +114,7 @@ function createProjectLogsStore(opts) {
116
114
  deletedAt: history.deleted ? history.deleted.at : null,
117
115
  deletedBy: history.deleted ? history.deleted.author : null,
118
116
  context: logContext.normalizeRecordContext(originRecord && originRecord.context),
117
+ scheduledResult: scheduledResultStore.normalizeMetadata(originRecord && originRecord.scheduledResult),
119
118
  };
120
119
  if (entry.context.changeSetId) {
121
120
  var states = knownContextStates || contexts.states();
@@ -132,13 +131,14 @@ function createProjectLogsStore(opts) {
132
131
  return entry;
133
132
  }
134
133
 
135
- function entries(includeDeleted) {
134
+ function entries(includeDeleted, scheduledResultOwnerId) {
136
135
  var out = [];
137
136
  var states = contexts.states();
138
137
  chains().forEach(function (chain, rootId) {
139
138
  var entry = fold(rootId, chain, states);
140
139
  if (!entry) return;
141
140
  if (entry.deleted && !includeDeleted) return;
141
+ if (!scheduledResultStore.visibleTo(entry.scheduledResult, scheduledResultOwnerId)) return;
142
142
  out.push(entry);
143
143
  });
144
144
  // Latest canonical update first, ref as a deterministic tie-break. Only a
@@ -157,12 +157,13 @@ function createProjectLogsStore(opts) {
157
157
  return found;
158
158
  }
159
159
 
160
- function read(ref, includeDeleted) {
160
+ function read(ref, includeDeleted, scheduledResultOwnerId) {
161
161
  var located = findChain(ref);
162
162
  if (!located) return null;
163
163
  var entry = fold(located.rootId, located.chain);
164
164
  if (!entry) return null;
165
165
  if (entry.deleted && !includeDeleted) return null;
166
+ if (!scheduledResultStore.visibleTo(entry.scheduledResult, scheduledResultOwnerId)) return null;
166
167
  return entry;
167
168
  }
168
169
 
@@ -212,8 +213,7 @@ function createProjectLogsStore(opts) {
212
213
  return read(refFor(id), false);
213
214
  }
214
215
 
215
- // Build the complete snapshot a revision will store, from the current state
216
- // plus the requested changes. Every new canonical edit is self-contained.
216
+ // Build the complete snapshot a revision will store from current state.
217
217
  function nextSnapshot(current, validated, data) {
218
218
  var snapshot = logsSnapshot.cloneSnapshot(current);
219
219
  if (validated.category !== undefined) snapshot.category = validated.category;
@@ -251,7 +251,6 @@ function createProjectLogsStore(opts) {
251
251
  }
252
252
 
253
253
  // One append per canonical revision, always carrying a complete snapshot.
254
- // Legacy field names ride along so an older build can still read the record.
255
254
  function appendRevision(rootId, op, snapshot, author, extra) {
256
255
  var record = {
257
256
  id: recordStore.newRecordId(),
@@ -275,9 +274,7 @@ function createProjectLogsStore(opts) {
275
274
  return record;
276
275
  }
277
276
 
278
- // Human participation. Append-only for this slice: no edit, no delete, and
279
- // never a canonical revision. The author is supplied by the authorization
280
- // layer, never by the caller's payload.
277
+ // Human participation is append-only and never a canonical revision.
281
278
  function comment(ref, input, author, context) {
282
279
  var located = requireLive(ref);
283
280
  var data = input || {};
@@ -344,7 +341,7 @@ function createProjectLogsStore(opts) {
344
341
  function categories(options) {
345
342
  var seen = {};
346
343
  var out = [];
347
- var live = entries(false);
344
+ var live = entries(false, options && options.scheduledResultOwnerId);
348
345
  for (var i = 0; i < live.length; i++) {
349
346
  if (!logsQuery.matchesContext(live[i], options || {})) continue;
350
347
  var value = live[i].category;
@@ -379,7 +376,7 @@ function createProjectLogsStore(opts) {
379
376
  var limit = Number(options2.limit);
380
377
  if (!Number.isFinite(limit)) limit = MAX_FEEDBACK_ITEMS;
381
378
  limit = Math.max(1, Math.min(MAX_FEEDBACK_ITEMS, Math.floor(limit)));
382
- var live = entries(false);
379
+ var live = entries(false, options2.scheduledResultOwnerId);
383
380
  var out = [];
384
381
  var total = 0;
385
382
  for (var i = 0; i < live.length; i++) {
@@ -409,13 +406,13 @@ function createProjectLogsStore(opts) {
409
406
 
410
407
  function list(args) {
411
408
  var options2 = args || {};
412
- var result = logsQuery.listEntries(entries(options2.includeDeleted === true), options2, cleanLine, validateKind);
409
+ var result = logsQuery.listEntries(entries(options2.includeDeleted === true, options2.scheduledResultOwnerId), options2, cleanLine, validateKind);
413
410
  result.categories = categories(options2);
414
411
  return result;
415
412
  }
416
413
 
417
414
  function search(args) {
418
- var result = logsQuery.searchEntries(entries(false), args || {}, cleanLine, validateKind);
415
+ var result = logsQuery.searchEntries(entries(false, args && args.scheduledResultOwnerId), args || {}, cleanLine, validateKind);
419
416
  result.categories = categories(args || {});
420
417
  return result;
421
418
  }
@@ -457,6 +454,8 @@ function createProjectLogsStore(opts) {
457
454
  };
458
455
  }
459
456
 
457
+ var scheduledResults = scheduledResultStore.attachScheduledResultStore({ store: store, scopeId: scopeId, refFor: refFor, read: read, entries: entries, findChain: findChain, matchesContext: logsQuery.matchesContext });
458
+
460
459
  return {
461
460
  root: root,
462
461
  scopeId: scopeId,
@@ -477,6 +476,9 @@ function createProjectLogsStore(opts) {
477
476
  search: search,
478
477
  history: history,
479
478
  stats: stats,
479
+ createScheduledResult: scheduledResults.create,
480
+ unreadScheduledResults: scheduledResults.unread,
481
+ markScheduledResultRead: scheduledResults.markRead,
480
482
  };
481
483
  }
482
484
 
@@ -72,6 +72,8 @@ var HANDLED_TYPES = [
72
72
  "project_log_read",
73
73
  "project_log_comment",
74
74
  "project_log_delete",
75
+ "scheduled_task_result_mark_read",
76
+ "scheduled_task_result_open_session",
75
77
  // Retired human mutations. Still claimed so an older client gets an
76
78
  // explicit refusal rather than silence.
77
79
  "project_log_create",
@@ -155,6 +157,31 @@ function attachProjectLogs(ctx) {
155
157
  return service.bindUser({ projectSlug: slug, user: (ws && ws._clayUser) || null });
156
158
  }
157
159
 
160
+ function scheduledResultState(bound, autoFocus) {
161
+ var state = bound.scheduledResultsState();
162
+ return { type: "scheduled_task_results_state", unreadCount: state.count, newestUnreadRef: state.newestRef, autoFocus: autoFocus === true };
163
+ }
164
+
165
+ function sendScheduledResultState(ws) {
166
+ if (!ws || ws._clayPane) return false;
167
+ var bound = userBinding(ws);
168
+ if (!bound) return false;
169
+ try { sendTo(ws, scheduledResultState(bound, true)); return true; } catch (e) { return false; }
170
+ }
171
+
172
+ function broadcastScheduledResultState(sourceWs) {
173
+ var sourceUserId = sourceWs && sourceWs._clayUser ? sourceWs._clayUser.id : null;
174
+ var clients = getClients();
175
+ for (var client of clients) {
176
+ if (!client || client.readyState !== 1 || client._clayPane) continue;
177
+ var clientUserId = client._clayUser ? client._clayUser.id : null;
178
+ if (clientUserId !== sourceUserId) continue;
179
+ var bound = userBinding(client);
180
+ if (!bound) continue;
181
+ try { sendTo(client, scheduledResultState(bound)); } catch (e) {}
182
+ }
183
+ }
184
+
158
185
  // --- WebSocket surface -----------------------------------------------
159
186
 
160
187
  function fail(ws, requestId, message) {
@@ -253,6 +280,27 @@ function attachProjectLogs(ctx) {
253
280
  sendTo(ws, { type: "project_log_commented", requestId: requestId, entry: commented, reviewQueued: reviewQueued });
254
281
  return true;
255
282
  }
283
+
284
+ if (msg.type === "scheduled_task_result_mark_read") {
285
+ bound.markScheduledResultRead({ ref: msg.ref });
286
+ broadcastScheduledResultState(ws);
287
+ return true;
288
+ }
289
+
290
+ if (msg.type === "scheduled_task_result_open_session") {
291
+ var resultEntry = bound.readLog({ ref: msg.ref });
292
+ var resultMeta = resultEntry && resultEntry.scheduledResult;
293
+ if (!resultMeta) throw new Error("Scheduled result not found.");
294
+ var target = null;
295
+ sm.sessions.forEach(function (session) {
296
+ if (target || !session || session.sessionOriginId !== resultMeta.driverOriginId) return;
297
+ var marker = session.scheduledTaskRun;
298
+ if ((session.ownerId || null) === (resultMeta.ownerId || null) && marker && marker.role === "driver" && marker.runId === resultMeta.runId && marker.scheduleId === resultMeta.scheduleId) target = session;
299
+ });
300
+ if (!target) throw new Error("This scheduled session is no longer available.");
301
+ sendTo(ws, { type: "scheduled_task_result_session", requestId: requestId, ref: resultEntry.ref, sessionId: target.localId });
302
+ return true;
303
+ }
256
304
  } catch (e) {
257
305
  fail(ws, requestId, (e && e.message) || "Project Logs could not complete the request.");
258
306
  return true;
@@ -378,6 +426,26 @@ function attachProjectLogs(ctx) {
378
426
  "\n" + logsMcp.REVIEW_CONTRACT + pendingFeedbackSignal(session);
379
427
  }
380
428
 
429
+ function recordScheduledResult(data) {
430
+ if (!service || isMate) return null;
431
+ var result = service.recordScheduledResult(Object.assign({}, data || {}, { projectSlug: slug }));
432
+ if (!result || !result.entry || !result.created) return result;
433
+ var clients = getClients();
434
+ for (var client of clients) {
435
+ if (!client || client.readyState !== 1 || client._clayPane) continue;
436
+ if (result.entry.scheduledResult.ownerId && (!client._clayUser || client._clayUser.id !== result.entry.scheduledResult.ownerId)) continue;
437
+ var bound = userBinding(client);
438
+ if (!bound) continue;
439
+ try { bound.readLog({ ref: result.entry.ref }); } catch (e) { continue; }
440
+ sendTo(client, {
441
+ type: "scheduled_task_result_created", ref: result.entry.ref,
442
+ title: result.entry.title, summary: result.entry.summary,
443
+ outcome: result.entry.scheduledResult.outcome,
444
+ });
445
+ }
446
+ return result;
447
+ }
448
+
381
449
  return {
382
450
  handleLogsMessage: handleLogsMessage,
383
451
  createMcpServer: createMcpServer,
@@ -386,6 +454,8 @@ function attachProjectLogs(ctx) {
386
454
  getBridgeTools: getBridgeTools,
387
455
  callBridgeTool: callBridgeTool,
388
456
  getSystemPrompt: getSystemPrompt,
457
+ recordScheduledResult: recordScheduledResult,
458
+ sendScheduledResultState: sendScheduledResultState,
389
459
  };
390
460
  }
391
461
 
@@ -373,6 +373,7 @@ function attachLoop(ctx) {
373
373
  if (durableSchedule) durableSchedule.sync();
374
374
  send({ type: "loop_registry_updated", records: getHubSchedules() });
375
375
  },
376
+ onRunTerminal: ctx.onScheduledRunTerminal,
376
377
  });
377
378
  loopRegistry.load();
378
379
  if (ctx.durableScheduler) {
@@ -0,0 +1,131 @@
1
+ // Server-owned Scheduled Tasks results inside the Project Logs record stream.
2
+ // This capability is never exposed through the human or agent write surfaces.
3
+
4
+ var crypto = require("crypto");
5
+ var recordStore = require("./knowledge-record-store");
6
+ var logsSchema = require("./project-logs-schema");
7
+ var logsSnapshot = require("./project-logs-snapshot");
8
+
9
+ var ID_PATTERN = /^[A-Za-z0-9_-]{1,200}$/;
10
+ var OUTCOMES = ["completed", "failed", "needs-input", "interrupted", "skipped-busy"];
11
+
12
+ function clean(value, max) {
13
+ return logsSchema.cleanLine(typeof value === "string" ? value : "", max);
14
+ }
15
+
16
+ function normalizeMetadata(value) {
17
+ var source = value || {};
18
+ if (!ID_PATTERN.test(source.runId || "") || !ID_PATTERN.test(source.scheduleId || "")) return null;
19
+ if (OUTCOMES.indexOf(source.outcome) === -1) return null;
20
+ return {
21
+ runId: source.runId,
22
+ scheduleId: source.scheduleId,
23
+ outcome: source.outcome,
24
+ ownerId: source.ownerId ? clean(String(source.ownerId), 120) : null,
25
+ driverOriginId: clean(source.driverOriginId || "", 200) || null,
26
+ workerOriginId: clean(source.workerOriginId || "", 200) || null,
27
+ driverProviderSessionId: clean(source.driverProviderSessionId || "", 200) || null,
28
+ workerProviderSessionId: clean(source.workerProviderSessionId || "", 200) || null,
29
+ startedAt: Number(source.startedAt) || null,
30
+ finishedAt: Number(source.finishedAt) || null,
31
+ };
32
+ }
33
+
34
+ function visibleTo(metadata, viewerId) {
35
+ if (!metadata || viewerId === undefined) return true;
36
+ return (metadata.ownerId || null) === (viewerId || null);
37
+ }
38
+
39
+ function outcomeLabel(outcome) {
40
+ if (outcome === "completed") return "Completed";
41
+ if (outcome === "needs-input") return "Needs input";
42
+ if (outcome === "interrupted") return "Interrupted";
43
+ if (outcome === "skipped-busy") return "Skipped";
44
+ return "Failed";
45
+ }
46
+
47
+ function fallbackSummary(outcome) {
48
+ if (outcome === "completed") return "The scheduled task completed successfully.";
49
+ if (outcome === "needs-input") return "The scheduled task is waiting for user input or permission.";
50
+ if (outcome === "interrupted") return "The scheduled task was interrupted before completion.";
51
+ if (outcome === "skipped-busy") return "The scheduled occurrence was skipped because an earlier run was still active.";
52
+ return "The scheduled task failed.";
53
+ }
54
+
55
+ function rootId(scopeId, runId) {
56
+ var digest = crypto.createHash("sha256").update(scopeId + "\u0000scheduled-result\u0000" + runId).digest("base64url");
57
+ return "sresult-" + digest.substring(0, 24);
58
+ }
59
+
60
+ function attachScheduledResultStore(ctx) {
61
+ function resultRecord(runId) {
62
+ var records = ctx.store.all();
63
+ for (var i = 0; i < records.length; i++) {
64
+ if (records[i].op === "create" && records[i].scheduledResult && records[i].scheduledResult.runId === runId) return records[i];
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function create(input, context) {
70
+ var data = input || {};
71
+ var metadata = normalizeMetadata(data);
72
+ if (!metadata) throw new Error("Scheduled result identity is invalid.");
73
+ var existing = resultRecord(metadata.runId);
74
+ if (existing) return { entry: ctx.read(ctx.refFor(existing.rootId || existing.id), false), created: false };
75
+ var name = clean(data.name || "Scheduled task", 160) || "Scheduled task";
76
+ var reason = clean(data.summary || data.reason || "", 400);
77
+ var summary = reason || fallbackSummary(metadata.outcome);
78
+ var title = name + " · " + outcomeLabel(metadata.outcome);
79
+ var body = "## Scheduled result\n\n" + summary + "\n\n" +
80
+ "- Outcome: " + outcomeLabel(metadata.outcome) + "\n" +
81
+ "- Run: `" + metadata.runId + "`\n" +
82
+ (metadata.startedAt ? "- Started: " + new Date(metadata.startedAt).toISOString() + "\n" : "") +
83
+ (metadata.finishedAt ? "- Finished: " + new Date(metadata.finishedAt).toISOString() + "\n" : "");
84
+ var id = rootId(ctx.scopeId, metadata.runId);
85
+ var snapshot = logsSnapshot.cloneSnapshot({ category: "scheduled-result", priority: metadata.outcome === "failed" ? "important" : "normal", title: title, summary: summary, body: body, tags: ["scheduled-task", metadata.outcome], links: [] });
86
+ ctx.store.append({
87
+ id: id, rootId: id, op: "create", scope: ctx.scopeId,
88
+ kind: snapshot.category, priority: snapshot.priority, title: snapshot.title,
89
+ summary: snapshot.summary, body: snapshot.body, tags: snapshot.tags,
90
+ links: snapshot.links, snapshot: snapshot,
91
+ author: logsSchema.normalizeAuthor({ type: "system", displayName: "Scheduled Tasks" }),
92
+ context: context || null, scheduledResult: metadata, at: metadata.finishedAt || Date.now(),
93
+ });
94
+ return { entry: ctx.read(ctx.refFor(id), false), created: true };
95
+ }
96
+
97
+ function hasRead(root, readerId) {
98
+ var records = ctx.store.all();
99
+ for (var i = 0; i < records.length; i++) {
100
+ if (records[i].rootId === root && records[i].op === "scheduled-result-read" && (records[i].readerId || null) === (readerId || null)) return true;
101
+ }
102
+ return false;
103
+ }
104
+
105
+ function unread(viewerId, options) {
106
+ var entries = ctx.entries(false, viewerId);
107
+ var refs = [];
108
+ for (var i = 0; i < entries.length; i++) {
109
+ if (!entries[i].scheduledResult) continue;
110
+ if (ctx.matchesContext && !ctx.matchesContext(entries[i], options || {})) continue;
111
+ var located = ctx.findChain(entries[i].ref);
112
+ if (located && !hasRead(located.rootId, viewerId)) refs.push(entries[i].ref);
113
+ }
114
+ return { count: refs.length, newestRef: refs.length ? refs[0] : null, refs: refs };
115
+ }
116
+
117
+ function markRead(ref, viewerId, options) {
118
+ var entry = ctx.read(ref, false, viewerId);
119
+ if (!entry || !entry.scheduledResult) throw new Error("Scheduled result not found.");
120
+ var located = ctx.findChain(ref);
121
+ if (!located) throw new Error("Scheduled result not found.");
122
+ if (!hasRead(located.rootId, viewerId)) {
123
+ ctx.store.append({ id: recordStore.newRecordId(), rootId: located.rootId, op: "scheduled-result-read", scope: ctx.scopeId, readerId: viewerId || null, at: Date.now() });
124
+ }
125
+ return unread(viewerId, options);
126
+ }
127
+
128
+ return { create: create, markRead: markRead, unread: unread };
129
+ }
130
+
131
+ module.exports = { attachScheduledResultStore: attachScheduledResultStore, normalizeMetadata: normalizeMetadata, visibleTo: visibleTo };
@@ -50,8 +50,9 @@ function attachScheduledTaskExecution(ctx) {
50
50
  var active = record.activeRun;
51
51
  var driver = sm.sessions.get(active.driverSessionId);
52
52
  var worker = sm.sessions.get(active.workerSessionId);
53
+ var finalSummary = clean(reason, 1000) || null;
53
54
  var saved = registry.completeRun(recordId, runId, {
54
- outcome: outcome, reason: clean(reason, 1000) || null,
55
+ outcome: outcome, summary: finalSummary, reason: finalSummary,
55
56
  driverProviderSessionId: driver && driver.cliSessionId || active.driverProviderSessionId || null,
56
57
  workerProviderSessionId: worker && worker.cliSessionId || active.workerProviderSessionId || null,
57
58
  });
@@ -65,6 +66,37 @@ function attachScheduledTaskExecution(ctx) {
65
66
  return true;
66
67
  }
67
68
 
69
+ function recordTerminal(record, run, attempt) {
70
+ if (!record || !run || !run.runId || !run.outcome || typeof ctx.recordResult !== "function") return false;
71
+ try {
72
+ ctx.recordResult({
73
+ scheduleId: record.id, runId: run.runId, name: record.name || "Scheduled task",
74
+ ownerId: record.ownerId || null, outcome: run.outcome,
75
+ summary: run.summary || run.reason || null, startedAt: run.startedAt || null,
76
+ finishedAt: run.finishedAt || Date.now(), driverOriginId: run.driverOriginId || null,
77
+ workerOriginId: run.workerOriginId || null,
78
+ driverProviderSessionId: run.driverProviderSessionId || null,
79
+ workerProviderSessionId: run.workerProviderSessionId || null,
80
+ });
81
+ return true;
82
+ } catch (error) {
83
+ console.error("[scheduled-task] Failed to record result for " + run.runId + ":", error.message || error);
84
+ if (Number(attempt || 0) < 2) setTimeout(function () { recordTerminal(record, run, Number(attempt || 0) + 1); }, 1000);
85
+ return false;
86
+ }
87
+ }
88
+
89
+ function syncTerminalResults() {
90
+ var records = registry.getAll ? registry.getAll() : [];
91
+ for (var i = 0; i < records.length; i++) {
92
+ if (records[i].createdViaScheduledTasks !== true && records[i].source !== "schedule") continue;
93
+ var runs = Array.isArray(records[i].runs) ? records[i].runs : [];
94
+ for (var j = 0; j < runs.length; j++) {
95
+ if (runs[j] && runs[j].runId && runs[j].outcome) recordTerminal(records[i], runs[j]);
96
+ }
97
+ }
98
+ }
99
+
68
100
  function failStart(recordId, runId, created, error, preDispatch) {
69
101
  if (preDispatch) rollbackPair(created);
70
102
  else if (created) {
@@ -110,6 +142,7 @@ function attachScheduledTaskExecution(ctx) {
110
142
  name: record.name || "Scheduled task",
111
143
  driver: record.execution.driver,
112
144
  worker: record.execution.worker,
145
+ hidden: true,
113
146
  });
114
147
  var runState = {
115
148
  status: "running", groupId: created.group.id,
@@ -230,7 +263,7 @@ function attachScheduledTaskExecution(ctx) {
230
263
  return driver ? stopForSession(driver, reason) : finish(record.id, record.activeRun.runId, "interrupted", reason, false);
231
264
  }
232
265
 
233
- return { getToolDefs: getToolDefs, onPartnerResult: onPartnerResult, onTurnDone: onTurnDone, prepare: prepare, runnable: runnable, stopForSession: stopForSession, stopRecord: stopRecord, trigger: trigger };
266
+ return { getToolDefs: getToolDefs, onPartnerResult: onPartnerResult, onTurnDone: onTurnDone, prepare: prepare, recordTerminal: recordTerminal, runnable: runnable, stopForSession: stopForSession, stopRecord: stopRecord, syncTerminalResults: syncTerminalResults, trigger: trigger };
234
267
  }
235
268
 
236
269
  module.exports = { attachScheduledTaskExecution: attachScheduledTaskExecution, validRuntime: validRuntime };
package/lib/project.js CHANGED
@@ -1422,6 +1422,9 @@ function createProjectContext(opts) {
1422
1422
  canExecuteScheduledTask: function (record) {
1423
1423
  return !!(_scheduledTaskExecution && _scheduledTaskExecution.runnable(record).ok);
1424
1424
  },
1425
+ onScheduledRunTerminal: function (record, run) {
1426
+ if (_scheduledTaskExecution) _scheduledTaskExecution.recordTerminal(record, run);
1427
+ },
1425
1428
  onProcessingChanged: onProcessingChanged,
1426
1429
  hydrateImageRefs: hydrateImageRefs,
1427
1430
  });
@@ -1437,6 +1440,7 @@ function createProjectContext(opts) {
1437
1440
  getSdk: function () { return sdk; }, getLinuxUserForSession: getLinuxUserForSession,
1438
1441
  requiresLinuxUser: function () { return osUsers; },
1439
1442
  onProcessingChanged: onProcessingChanged,
1443
+ recordResult: function (result) { return _projectLogs.recordScheduledResult(result); },
1440
1444
  authorize: function (record) {
1441
1445
  return authorizeStoredOwner(record, {
1442
1446
  usersModule: usersModule, osUsers: osUsers,
@@ -1446,6 +1450,7 @@ function createProjectContext(opts) {
1446
1450
  });
1447
1451
  },
1448
1452
  });
1453
+ setTimeout(function () { _scheduledTaskExecution.syncTerminalResults(); }, 0);
1449
1454
 
1450
1455
  _scheduledTasks = attachScheduledTasks({
1451
1456
  cwd: cwd,
@@ -1940,6 +1945,7 @@ function createProjectContext(opts) {
1940
1945
  saveContextSources: saveContextSources,
1941
1946
  _email: _email,
1942
1947
  _notifications: _notifications,
1948
+ _projectLogs: _projectLogs,
1943
1949
  });
1944
1950
 
1945
1951
  var _models = attachModels({
package/lib/public/app.js CHANGED
@@ -381,6 +381,12 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
381
381
  projectLogsCanDelete: false,
382
382
  projectLogsUnread: 0,
383
383
  projectLogsSeenRevisions: {},
384
+ scheduledResultUnread: 0,
385
+ scheduledResultAutoFocusHandled: false,
386
+ scheduledResultFocusRef: null,
387
+ scheduledResultPendingReadRef: null,
388
+ scheduledResultSeenRefs: {},
389
+ scheduledResultSessionRequestId: null,
384
390
  issuesOpen: false,
385
391
  issuesView: "list",
386
392
  issuesSelected: null,
@@ -181,7 +181,7 @@
181
181
  margin-top: 8px;
182
182
  }
183
183
  .notif-banner-allow, .notif-banner-always, .notif-banner-deny, .notif-banner-goto,
184
- .notif-banner-tui-open, .notif-banner-tui-goto {
184
+ .notif-banner-tui-open, .notif-banner-tui-goto, .notif-banner-result-open {
185
185
  padding: 4px 10px;
186
186
  border-radius: 6px;
187
187
  font-size: 11px;
@@ -191,11 +191,11 @@
191
191
  border: none;
192
192
  transition: opacity 0.15s, background 0.15s;
193
193
  }
194
- .notif-banner-tui-open {
194
+ .notif-banner-tui-open, .notif-banner-result-open {
195
195
  background: var(--accent, #6c5ce7);
196
196
  color: #fff;
197
197
  }
198
- .notif-banner-tui-open:hover { opacity: 0.85; }
198
+ .notif-banner-tui-open:hover, .notif-banner-result-open:hover { opacity: 0.85; }
199
199
  .notif-banner-tui-goto {
200
200
  background: var(--accent2);
201
201
  border: none;
@@ -196,3 +196,18 @@
196
196
  .project-log-row { padding: 14px 14px 13px; }
197
197
  .project-log-document { padding: 24px 18px 38px; }
198
198
  }
199
+ .project-log-session-button {
200
+ align-self: flex-start;
201
+ display: inline-flex;
202
+ align-items: center;
203
+ gap: 6px;
204
+ margin: 0 0 16px;
205
+ border: 1px solid var(--border);
206
+ border-radius: 8px;
207
+ padding: 7px 10px;
208
+ background: var(--bg-alt);
209
+ color: var(--text);
210
+ cursor: pointer;
211
+ }
212
+
213
+ .project-log-session-button:hover { border-color: var(--accent); }
@@ -44,7 +44,7 @@ import { tuiModalHandleTermOutput, tuiModalHandleTermResized, tuiModalHandleTerm
44
44
  import { updateTerminalList, handleContextSourcesState, updateEmailAccountList, updateEmailUnreadCounts, handleEmailTestResult, handleEmailAddResult, handleEmailRemoveResult } from './context-sources.js';
45
45
  import { refreshEmailSettings } from './user-settings.js';
46
46
  import { handleNotesList, handleNoteCreated, handleNoteUpdated, handleNoteDeleted, handleNoteWritten } from './sticky-notes.js';
47
- import { handleProjectLogsState, handleProjectLogEntry, handleProjectLogCommented, handleProjectLogCommentReviewed, handleProjectLogDeleted, handleProjectLogUpdated, handleProjectLogsError } from './project-logs.js';
47
+ import { handleProjectLogsState, handleProjectLogEntry, handleProjectLogCommented, handleProjectLogCommentReviewed, handleProjectLogDeleted, handleProjectLogUpdated, handleProjectLogsError, handleScheduledTaskResultsState, handleScheduledTaskResultCreated, handleScheduledTaskResultSession } from './project-logs.js';
48
48
  import { handleSkillInstalled, handleSkillUninstalled } from './skills.js';
49
49
  import { showRewindModal, onRewindComplete, setRewindMode, onRewindError, clearPendingRewindUuid, addRewindButton } from './rewind.js';
50
50
  import { checkAdminAccess } from './admin.js';
@@ -1664,6 +1664,18 @@ export function processMessage(msg) {
1664
1664
  handleProjectLogsError(msg);
1665
1665
  break;
1666
1666
 
1667
+ case "scheduled_task_results_state":
1668
+ handleScheduledTaskResultsState(msg);
1669
+ break;
1670
+
1671
+ case "scheduled_task_result_created":
1672
+ handleScheduledTaskResultCreated(msg);
1673
+ break;
1674
+
1675
+ case "scheduled_task_result_session":
1676
+ handleScheduledTaskResultSession(msg);
1677
+ break;
1678
+
1667
1679
  case "process_stats":
1668
1680
  updateStatusPanel(msg);
1669
1681
  updateSettingsStats(msg);
@@ -45,6 +45,7 @@ function actorLabel(actor) {
45
45
  // only the account the session ran under.
46
46
  function authorLine(entry) {
47
47
  var actor = (entry && (entry.updatedBy || entry.createdBy)) || {};
48
+ if (actor.type === "system") return actor.displayName || "Scheduled Tasks";
48
49
  if (actor.type === "session") {
49
50
  return actor.vendor ? "Project Driver (" + vendorLabel(actor.vendor) + ")" : "Project Driver";
50
51
  }
@@ -363,7 +364,7 @@ export function renderDetail(detailEl, entry, handlers) {
363
364
  formatTime(entry.updatedAt || entry.createdAt),
364
365
  ].filter(Boolean).join(" · ");
365
366
  setText(header, ".project-log-byline", byline);
366
- if (handlers && handlers.canDelete) {
367
+ if (handlers && handlers.canDelete && !entry.scheduledResult) {
367
368
  var deleteButton = document.createElement("button");
368
369
  deleteButton.type = "button";
369
370
  deleteButton.className = "project-log-delete-button";
@@ -373,6 +374,15 @@ export function renderDetail(detailEl, entry, handlers) {
373
374
  }
374
375
  article.appendChild(header);
375
376
 
377
+ if (entry.scheduledResult && entry.scheduledResult.driverOriginId && handlers && handlers.onOpenSession) {
378
+ var sessionButton = document.createElement("button");
379
+ sessionButton.type = "button";
380
+ sessionButton.className = "project-log-session-button";
381
+ sessionButton.innerHTML = '<i data-lucide="external-link"></i><span>View session</span>';
382
+ sessionButton.addEventListener("click", function () { handlers.onOpenSession(entry); });
383
+ article.appendChild(sessionButton);
384
+ }
385
+
376
386
  var markdown = document.createElement("div");
377
387
  markdown.className = "project-log-markdown md-content";
378
388
  markdown.innerHTML = renderMarkdown(entry.body || "");
@@ -1,5 +1,4 @@
1
1
  // Project Logs — the project's ledger of agent-authored decisions and work.
2
- //
3
2
  // A bounded right-side workbench pane, like the document viewer and terminal.
4
3
  // Inside it there is one navigation stack rather than a master/detail split:
5
4
  // the list fills the pane, opening an entry replaces it with the full-pane
@@ -14,6 +13,9 @@ import { store } from './store.js';
14
13
  import { getWs } from './ws-ref.js';
15
14
  import { refreshIcons } from './icons.js';
16
15
  import { showToast } from './utils.js';
16
+ import { showScheduledResultNotification } from './scheduled-result-notification.js';
17
+ import * as scheduledResultClient from './scheduled-result-client.js';
18
+ import { clearPendingScheduledResultRead, handleScheduledResultDisplayed, initScheduledResultDisplay, openScheduledResultSession } from './scheduled-result-display.js';
17
19
  import { closeScheduledTasks } from './scheduled-tasks.js';
18
20
  import { closeNotesBrowser } from './sticky-notes-browser.js';
19
21
  import { closeFileViewer } from './filebrowser.js';
@@ -35,7 +37,7 @@ var requestCounter = 0;
35
37
  function syncUnreadBadge() {
36
38
  var button = document.getElementById("project-logs-btn");
37
39
  if (!button) return;
38
- var unread = store.get('projectLogsUnread') || 0;
40
+ var unread = (store.get('projectLogsUnread') || 0) + (store.get('scheduledResultUnread') || 0);
39
41
  button.classList.toggle("project-logs-unread", unread > 0);
40
42
  var badge = document.getElementById("project-logs-count");
41
43
  if (!badge) return;
@@ -164,10 +166,10 @@ function applyWindowState(wide, fullscreen) {
164
166
  }
165
167
 
166
168
  // --- Navigation stack ----------------------------------------------------
167
-
168
169
  function showList() {
169
170
  if (!panel) return;
170
171
  store.set({ projectLogsView: "list", projectLogsSelectedRef: null });
172
+ clearPendingScheduledResultRead();
171
173
  detailEl.classList.add("hidden");
172
174
  listEl.classList.remove("hidden");
173
175
  panel.querySelector("#project-logs-toolbar").classList.remove("hidden");
@@ -188,8 +190,10 @@ function showDetail(entry) {
188
190
  renderDetail(detailEl, entry, {
189
191
  onComment: submitComment,
190
192
  onDelete: openDeleteDialog,
193
+ onOpenSession: openScheduledResultSession,
191
194
  canDelete: store.get('projectLogsCanDelete') === true,
192
195
  });
196
+ handleScheduledResultDisplayed(entry);
193
197
  }
194
198
 
195
199
  function openDeleteDialog(entry) {
@@ -230,7 +234,6 @@ function confirmDelete() {
230
234
  }
231
235
 
232
236
  // --- Requests ------------------------------------------------------------
233
-
234
237
  function requestList() {
235
238
  var requestId = nextRequestId("list-logs");
236
239
  store.set({ projectLogsListRequestId: requestId });
@@ -288,8 +291,10 @@ export function initProjectLogs() {
288
291
  if (store.get('projectLogsOpen')) closeProjectLogs();
289
292
  else openProjectLogs();
290
293
  });
294
+ initScheduledResultDisplay();
291
295
  syncUnreadBadge();
292
296
  store.subscribe(function (state, previous) {
297
+ if (state.connected === false && previous.connected === true) store.set({ scheduledResultAutoFocusHandled: false });
293
298
  if (state.currentSlug === previous.currentSlug) return;
294
299
  closeProjectLogs();
295
300
  applyWindowState(false, false);
@@ -309,7 +314,13 @@ export function initProjectLogs() {
309
314
  projectLogsDeleteRequestId: null,
310
315
  projectLogsCanDelete: false,
311
316
  projectLogsUnread: 0,
312
- projectLogsSeenRevisions: {}
317
+ projectLogsSeenRevisions: {},
318
+ scheduledResultUnread: 0,
319
+ scheduledResultAutoFocusHandled: false,
320
+ scheduledResultFocusRef: null,
321
+ scheduledResultPendingReadRef: null,
322
+ scheduledResultSeenRefs: {},
323
+ scheduledResultSessionRequestId: null,
313
324
  });
314
325
  syncUnreadBadge();
315
326
  });
@@ -384,11 +395,35 @@ export function handleProjectLogsState(msg) {
384
395
 
385
396
  export function handleProjectLogEntry(msg) {
386
397
  if (msg.requestId && msg.requestId !== store.get('projectLogsReadRequestId')) return;
387
- if (!msg.entry) return;
398
+ if (!msg.entry || !store.get('projectLogsOpen')) return;
388
399
  store.set({ projectLogsCanDelete: msg.canDelete === true });
389
400
  showDetail(msg.entry);
390
401
  }
391
402
 
403
+ export function handleScheduledTaskResultsState(msg) {
404
+ var transition = scheduledResultClient.scheduledResultStateTransition(store.snap(), msg);
405
+ store.set(transition.patch); syncUnreadBadge();
406
+ if (!transition.openRef) return false;
407
+ var opened = openProjectLog(transition.openRef);
408
+ if (!opened) store.set({ scheduledResultFocusRef: null });
409
+ return opened;
410
+ }
411
+
412
+ export function handleScheduledTaskResultCreated(msg) {
413
+ var patch = scheduledResultClient.scheduledResultCreatedTransition(store.snap(), msg);
414
+ if (!patch) return false;
415
+ store.set(patch); syncUnreadBadge();
416
+ showScheduledResultNotification(msg.title || "Scheduled task finished", msg.summary || "", function () { openProjectLog(msg.ref); });
417
+ return true;
418
+ }
419
+
420
+ export function handleScheduledTaskResultSession(msg) {
421
+ var sessionId = scheduledResultClient.scheduledResultSessionId(store.snap(), msg);
422
+ if (sessionId === null) return false;
423
+ store.set({ scheduledResultSessionRequestId: null });
424
+ return send({ type: "switch_session", id: sessionId });
425
+ }
426
+
392
427
  export function handleProjectLogCommented(msg) {
393
428
  if (msg.requestId && msg.requestId !== store.get('projectLogsCommentRequestId')) return;
394
429
  if (!msg.entry) return;
@@ -0,0 +1,33 @@
1
+ // Pure Scheduled Tasks result-news decisions. The production controller owns
2
+ // store, WebSocket, DOM, and peer-module calls directly.
3
+
4
+ export function scheduledResultStateTransition(state, msg) {
5
+ var patch = { scheduledResultUnread: Number(msg.unreadCount) || 0 };
6
+ var openRef = null;
7
+ if (msg.autoFocus && msg.newestUnreadRef && !state.scheduledResultAutoFocusHandled) {
8
+ patch.scheduledResultAutoFocusHandled = true;
9
+ patch.scheduledResultFocusRef = msg.newestUnreadRef;
10
+ openRef = msg.newestUnreadRef;
11
+ }
12
+ return { patch: patch, openRef: openRef };
13
+ }
14
+
15
+ export function scheduledResultCreatedTransition(state, msg) {
16
+ if (!msg || !msg.ref || (state.scheduledResultSeenRefs || {})[msg.ref]) return null;
17
+ var seen = Object.assign({}, state.scheduledResultSeenRefs || {});
18
+ seen[msg.ref] = true;
19
+ return { scheduledResultSeenRefs: seen, scheduledResultUnread: (state.scheduledResultUnread || 0) + 1 };
20
+ }
21
+
22
+ export function shouldAcknowledgeScheduledResult(entry, state, visibilityState) {
23
+ return !!(entry && entry.scheduledResult && visibilityState !== "hidden" && state.projectLogsOpen && state.projectLogsView === "detail" && state.projectLogsSelectedRef === entry.ref);
24
+ }
25
+
26
+ export function shouldFocusScheduledResult(entry, state) {
27
+ return !!(entry && state.scheduledResultFocusRef === entry.ref);
28
+ }
29
+
30
+ export function scheduledResultSessionId(state, msg) {
31
+ if (msg.requestId && msg.requestId !== state.scheduledResultSessionRequestId) return null;
32
+ return typeof msg.sessionId === "number" ? msg.sessionId : null;
33
+ }
@@ -0,0 +1,50 @@
1
+ import { store } from './store.js';
2
+ import { getWs } from './ws-ref.js';
3
+ import { showToast } from './utils.js';
4
+ import { shouldAcknowledgeScheduledResult, shouldFocusScheduledResult } from './scheduled-result-client.js';
5
+
6
+ function sendRead(ref) {
7
+ var ws = getWs();
8
+ if (!ws || ws.readyState !== 1) return false;
9
+ ws.send(JSON.stringify({ type: "scheduled_task_result_mark_read", ref: ref }));
10
+ return true;
11
+ }
12
+
13
+ var openRequestCounter = 0;
14
+ export function openScheduledResultSession(entry) {
15
+ if (!entry || !entry.scheduledResult) return false;
16
+ openRequestCounter += 1;
17
+ var requestId = "open-scheduled-session-" + Date.now() + "-" + openRequestCounter;
18
+ store.set({ scheduledResultSessionRequestId: requestId });
19
+ var ws = getWs();
20
+ if (!ws || ws.readyState !== 1) { showToast("Reconnect to view this scheduled session.", "warn"); return false; }
21
+ ws.send(JSON.stringify({ type: "scheduled_task_result_open_session", requestId: requestId, ref: entry.ref })); return true;
22
+ }
23
+
24
+ function acknowledgeVisible() {
25
+ var ref = store.get('scheduledResultPendingReadRef');
26
+ if (!ref || document.visibilityState === "hidden") return;
27
+ store.set({ scheduledResultPendingReadRef: null });
28
+ var state = store.snap();
29
+ if (state.projectLogsOpen && state.projectLogsView === "detail" && state.projectLogsSelectedRef === ref) sendRead(ref);
30
+ }
31
+
32
+ export function clearPendingScheduledResultRead() {
33
+ store.set({ scheduledResultPendingReadRef: null });
34
+ }
35
+
36
+ export function handleScheduledResultDisplayed(entry) {
37
+ var state = store.snap();
38
+ if (shouldFocusScheduledResult(entry, state)) {
39
+ store.set({ scheduledResultFocusRef: null });
40
+ var heading = document.querySelector("#project-logs-detail .project-log-document h1");
41
+ if (heading) { heading.setAttribute("tabindex", "-1"); heading.focus(); }
42
+ }
43
+ if (shouldAcknowledgeScheduledResult(entry, store.snap(), document.visibilityState)) {
44
+ clearPendingScheduledResultRead(); sendRead(entry.ref);
45
+ } else if (entry && entry.scheduledResult && document.visibilityState === "hidden") store.set({ scheduledResultPendingReadRef: entry.ref });
46
+ }
47
+
48
+ export function initScheduledResultDisplay() {
49
+ document.addEventListener("visibilitychange", acknowledgeVisible);
50
+ }
@@ -0,0 +1,31 @@
1
+ import { refreshIcons, iconHtml } from './icons.js';
2
+ import { escapeHtml } from './utils.js';
3
+
4
+ function removeBanner(banner) {
5
+ if (!banner || !banner.parentNode) return;
6
+ banner.classList.remove("show");
7
+ setTimeout(function () { if (banner.parentNode) banner.remove(); }, 220);
8
+ }
9
+
10
+ export function showScheduledResultNotification(title, body, onOpen) {
11
+ var container = document.querySelector(".notif-banner-container");
12
+ if (!container) return false;
13
+ var banner = document.createElement("div");
14
+ banner.className = "notif-banner";
15
+ banner.innerHTML =
16
+ '<div class="notif-banner-icon">' + iconHtml("calendar-check") + '</div>' +
17
+ '<div class="notif-banner-body"><div class="notif-banner-project">SCHEDULED TASKS</div>' +
18
+ '<div class="notif-banner-title">' + escapeHtml(title || "Scheduled task finished") + '</div>' +
19
+ (body ? '<div class="notif-banner-text">' + escapeHtml(body) + '</div>' : '') +
20
+ '<div class="notif-banner-actions"><button class="notif-banner-result-open">View result</button></div></div>' +
21
+ '<button class="notif-banner-close" aria-label="Dismiss notification">' + iconHtml("x") + '</button>';
22
+ container.appendChild(banner);
23
+ refreshIcons(banner);
24
+ requestAnimationFrame(function () { banner.classList.add("show"); });
25
+ banner.querySelector(".notif-banner-result-open").addEventListener("click", function (event) {
26
+ event.stopPropagation(); removeBanner(banner); if (typeof onOpen === "function") onOpen();
27
+ });
28
+ banner.querySelector(".notif-banner-close").addEventListener("click", function (event) { event.stopPropagation(); removeBanner(banner); });
29
+ setTimeout(function () { removeBanner(banner); }, 8000);
30
+ return true;
31
+ }
@@ -0,0 +1,15 @@
1
+ export function scheduledTaskExecutionLabel(record) {
2
+ if (record.activeRun) {
3
+ if (record.activeRun.status === "needs-input") return "Needs input";
4
+ if (record.activeRun.status === "waiting-worker") return "Waiting for Worker";
5
+ if (record.activeRun.status === "reviewing") return "Driver reviewing";
6
+ return "Running";
7
+ }
8
+ if (!record.execution) return "Needs setup";
9
+ if (record.readiness && record.readiness.ok === false) return "Unavailable";
10
+ return record.enabled === false ? "Paused" : "Active";
11
+ }
12
+
13
+ export function scheduledTaskOpenRunLabel(record) {
14
+ return record.activeRun && record.activeRun.status === "needs-input" ? "Open blocked run" : "Open run";
15
+ }
@@ -12,6 +12,7 @@ import { readExecutionRuntime, renderExecutionRuntime } from './scheduled-task-e
12
12
  import { openDefaultAi } from './default-ai.js';
13
13
  import { reconcileScheduledTaskEdit } from './scheduled-task-edit-state.js';
14
14
  import { editorFields, fillEditor, readEditor, serverEditorData, validateEditor, handleEditorScheduleChange } from './scheduled-task-form.js';
15
+ import { scheduledTaskExecutionLabel, scheduledTaskOpenRunLabel } from './scheduled-task-presentation.js';
15
16
 
16
17
  export { isStrictCron, humanRecurrence } from './scheduled-task-recurrence.js';
17
18
 
@@ -56,18 +57,6 @@ function scheduleLabel(record) {
56
57
  return "No schedule";
57
58
  }
58
59
 
59
- function executionLabel(record) {
60
- if (record.activeRun) {
61
- if (record.activeRun.status === "needs-input") return "Needs input";
62
- if (record.activeRun.status === "waiting-worker") return "Waiting for Worker";
63
- if (record.activeRun.status === "reviewing") return "Driver reviewing";
64
- return "Running";
65
- }
66
- if (!record.execution) return "Needs setup";
67
- if (record.readiness && record.readiness.ok === false) return "Unavailable";
68
- return record.enabled === false ? "Paused" : "Active";
69
- }
70
-
71
60
  function ensurePanel() {
72
61
  if (panel) return;
73
62
  var host = document.getElementById("main-panels");
@@ -185,7 +174,7 @@ function buildRow(record) {
185
174
  row.className = "scheduled-task-row" + (record.enabled === false ? " paused" : "");
186
175
  row.dataset.taskId = record.id;
187
176
  row.setAttribute("role", "listitem");
188
- var stateLabel = executionLabel(record);
177
+ var stateLabel = scheduledTaskExecutionLabel(record);
189
178
  row.innerHTML =
190
179
  '<button class="scheduled-task-summary" type="button" data-action="expand" aria-expanded="' + (expandedId === record.id ? "true" : "false") + '">' +
191
180
  '<span class="scheduled-task-state-dot" aria-hidden="true"></span>' +
@@ -212,12 +201,12 @@ function buildRow(record) {
212
201
  detail.appendChild(instructions);
213
202
  var context = document.createElement("div");
214
203
  context.className = "scheduled-task-context";
215
- var executionState = executionLabel(record);
204
+ var executionState = scheduledTaskExecutionLabel(record);
216
205
  context.textContent = (record.projectTitle || store.get('currentSlug') || "Project") + " · " + executionState + (record.execution ? " · Driver and Split Worker" : " · Choose execution pair in Edit");
217
206
  detail.appendChild(context);
218
207
  var actions = document.createElement("div");
219
208
  actions.className = "scheduled-task-actions";
220
- actions.innerHTML = '<button type="button" data-action="edit">' + iconHtml("pencil") + 'Edit</button><button type="button" data-action="toggle">' + iconHtml(record.enabled === false ? "play" : "pause") + (record.enabled === false ? "Resume" : "Pause") + '</button><button type="button" data-action="run">' + iconHtml("zap") + 'Run now</button>' + (record.activeRun || record.latestRun ? '<button type="button" data-action="open-run">' + iconHtml("columns-2") + 'Open run</button>' : '') + '<button type="button" data-action="delete" class="danger">' + iconHtml("trash-2") + 'Delete</button>';
209
+ actions.innerHTML = '<button type="button" data-action="edit">' + iconHtml("pencil") + 'Edit</button><button type="button" data-action="toggle">' + iconHtml(record.enabled === false ? "play" : "pause") + (record.enabled === false ? "Resume" : "Pause") + '</button><button type="button" data-action="run">' + iconHtml("zap") + 'Run now</button>' + (record.activeRun || record.latestRun ? '<button type="button" data-action="open-run">' + iconHtml("columns-2") + scheduledTaskOpenRunLabel(record) + '</button>' : '') + '<button type="button" data-action="delete" class="danger">' + iconHtml("trash-2") + 'Delete</button>';
221
210
  detail.appendChild(actions);
222
211
  }
223
212
  row.appendChild(detail);
@@ -42,9 +42,11 @@ function createScheduledRunState(ctx) {
42
42
  }
43
43
 
44
44
  function complete(id, runId, result) {
45
- return mutate(id, function (rec) {
45
+ var completed = null;
46
+ var saved = mutate(id, function (rec) {
46
47
  if (!rec.activeRun || rec.activeRun.runId !== runId) return false;
47
48
  var run = Object.assign({}, rec.activeRun, result || {}, { finishedAt: Date.now() });
49
+ completed = run;
48
50
  rec.lastRunAt = run.startedAt || Date.now();
49
51
  rec.lastRunResult = run.outcome || "failed";
50
52
  rec.runs = Array.isArray(rec.runs) ? rec.runs : [];
@@ -54,6 +56,8 @@ function createScheduledRunState(ctx) {
54
56
  rec.activeRun = null;
55
57
  return true;
56
58
  });
59
+ if (saved && completed && ctx.onTerminal) ctx.onTerminal(ctx.getRecord(id), completed);
60
+ return saved;
57
61
  }
58
62
 
59
63
  function recover(rec) {
@@ -81,7 +85,11 @@ function createScheduledRunState(ctx) {
81
85
  return true;
82
86
  }
83
87
 
84
- return { begin: begin, complete: complete, prepareRecord: prepareRecord, recordLegacy: recordLegacy, recover: recover, update: update };
88
+ function notifyTerminal(rec, run) {
89
+ if (ctx.onTerminal && rec && run) ctx.onTerminal(rec, run);
90
+ }
91
+
92
+ return { begin: begin, complete: complete, notifyTerminal: notifyTerminal, prepareRecord: prepareRecord, recordLegacy: recordLegacy, recover: recover, update: update };
85
93
  }
86
94
 
87
95
  module.exports = { createScheduledRunState: createScheduledRunState };
package/lib/scheduler.js CHANGED
@@ -36,6 +36,7 @@ function createLoopRegistry(opts) {
36
36
  var scheduledRuns = createScheduledRunState({
37
37
  getRecord: getById, save: save,
38
38
  onChange: function () { if (onChange) onChange(records); },
39
+ onTerminal: opts.onRunTerminal,
39
40
  });
40
41
  var durableOccurrence;
41
42
 
@@ -235,12 +235,12 @@ function attachPairFactory(ctx) {
235
235
  var request = args || {};
236
236
  var driverRuntime = preflightRuntime(request.driver || {}, "claude");
237
237
  var workerRuntime = preflightRuntime(request.worker || {}, null);
238
- var driver = sm.createSessionRaw({ ownerId: ownerId || null, vendor: driverRuntime.vendor, model: driverRuntime.model, effort: driverRuntime.effort });
238
+ var driver = sm.createSessionRaw({ ownerId: ownerId || null, vendor: driverRuntime.vendor, model: driverRuntime.model, effort: driverRuntime.effort, hidden: request.hidden === true });
239
239
  var worker = null;
240
240
  try {
241
241
  driver.title = request.name ? request.name + " · Driver" : "Scheduled task · Driver";
242
242
  if (driverRuntime.explicitEffort) driver.loopSettings = { effort: driverRuntime.effort };
243
- worker = sm.createSessionRaw({ ownerId: ownerId || null, vendor: workerRuntime.vendor, model: workerRuntime.model, effort: workerRuntime.effort });
243
+ worker = sm.createSessionRaw({ ownerId: ownerId || null, vendor: workerRuntime.vendor, model: workerRuntime.model, effort: workerRuntime.effort, hidden: request.hidden === true });
244
244
  worker.title = request.name ? request.name + " · Split Worker" : "Scheduled task · Split Worker";
245
245
  if (workerRuntime.explicitEffort) worker.loopSettings = { effort: workerRuntime.effort };
246
246
  sessionProvenance.markWorker(driver, worker, sm.sessions);
package/lib/sessions.js CHANGED
@@ -177,6 +177,7 @@ function createSessionManager(opts) {
177
177
  if (session.loopInterviewHandoff) metaObj.loopInterviewHandoff = session.loopInterviewHandoff;
178
178
  if (session.scheduledTaskDraft) metaObj.scheduledTaskDraft = session.scheduledTaskDraft;
179
179
  if (session.scheduledTaskRun) metaObj.scheduledTaskRun = session.scheduledTaskRun;
180
+ if (session.hidden) metaObj.hidden = true;
180
181
  metaObj.sessionVisibility = sessionVisibility.normalize(session.sessionVisibility);
181
182
  if (session.sessionVisibilityExplicit) metaObj.sessionVisibilityExplicit = true;
182
183
  if (session.bookmarked) metaObj.bookmarked = true;
@@ -338,6 +339,7 @@ function createSessionManager(opts) {
338
339
  session.loopInterviewHandoff = m.loopInterviewHandoff && typeof m.loopInterviewHandoff === "object" ? m.loopInterviewHandoff : null;
339
340
  session.scheduledTaskDraft = m.scheduledTaskDraft && typeof m.scheduledTaskDraft === "object" ? m.scheduledTaskDraft : null;
340
341
  session.scheduledTaskRun = m.scheduledTaskRun && typeof m.scheduledTaskRun === "object" ? m.scheduledTaskRun : null;
342
+ session.hidden = m.hidden === true || !!session.scheduledTaskRun;
341
343
  session.terminalId = null;
342
344
  session.runtimeMode = null;
343
345
  session.runtimeTerminalId = null;
@@ -664,6 +666,7 @@ function createSessionManager(opts) {
664
666
  loopInterviewHandoff: null,
665
667
  scheduledTaskDraft: null,
666
668
  scheduledTaskRun: null,
669
+ hidden: !!(sessionOpts && sessionOpts.hidden),
667
670
  terminalId: null,
668
671
  sessionOriginId: null,
669
672
  };
@@ -711,6 +714,7 @@ function createSessionManager(opts) {
711
714
  loopInterviewHandoff: null,
712
715
  scheduledTaskDraft: null,
713
716
  scheduledTaskRun: null,
717
+ hidden: !!(sessionOpts && sessionOpts.hidden),
714
718
  terminalId: null,
715
719
  sessionOriginId: null,
716
720
  };
package/lib/ws-schema.js CHANGED
@@ -81,6 +81,11 @@ var schema = {
81
81
  "scheduled_task_update": { direction: "c2s", handler: "lib/project-scheduled-tasks.js", description: "Update an exact schedule revision" },
82
82
  "scheduled_task_cancel": { direction: "c2s", handler: "lib/project-scheduled-tasks.js", description: "Cancel the active schedule interview or draft" },
83
83
  "scheduled_task_action_result": { direction: "s2c", handler: "lib/public/modules/scheduled-tasks.js", description: "Correlated create or edit result" },
84
+ "scheduled_task_results_state": { direction: "s2c", handler: "lib/public/modules/project-logs.js", description: "Authorized per-user unread Scheduled Tasks result state" },
85
+ "scheduled_task_result_created": { direction: "s2c", handler: "lib/public/modules/project-logs.js", description: "Live terminal Scheduled Tasks result notification" },
86
+ "scheduled_task_result_mark_read": { direction: "c2s", handler: "lib/project-logs.js", description: "Mark an exact displayed scheduled result read for the current user" },
87
+ "scheduled_task_result_open_session": { direction: "c2s", handler: "lib/project-logs.js", description: "Resolve the hidden Driver for an authorized scheduled result" },
88
+ "scheduled_task_result_session": { direction: "s2c", handler: "lib/public/modules/project-logs.js", description: "Authorized hidden scheduled Driver navigation target" },
84
89
  "search_results": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Session title search results" },
85
90
  "search_content_results": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Full-text content search results" },
86
91
  "fork_complete": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Fork succeeded, includes new session ID" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "4.1.0-beta.10",
3
+ "version": "4.1.0-beta.12",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js"
@@ -8,8 +8,6 @@
8
8
  "scripts": {
9
9
  "dev": "node bin/cli.js --dev",
10
10
  "test": "node --test --test-force-exit test/*.test.js",
11
- "prepack": "npm pkg delete bin.clay-dev",
12
- "postpack": "npm pkg set bin.clay-dev=./bin/cli.js",
13
11
  "semantic-release": "semantic-release"
14
12
  },
15
13
  "files": [