shraga 0.1.66 → 0.1.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/server/boot.ts +33 -2
- package/src/server/polls.ts +15 -2
- package/src/server/scheduler/outcome.ts +92 -0
- package/src/server/scheduler/runner.ts +98 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.67",
|
|
4
4
|
"description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
package/src/server/boot.ts
CHANGED
|
@@ -1134,10 +1134,41 @@ if (!PASSIVE) {
|
|
|
1134
1134
|
// runs in passive too. Otherwise a standby instance reports empty stats and /api/stats is a lie.
|
|
1135
1135
|
statsSampler.start(broadcast);
|
|
1136
1136
|
registerEventRoutes(app, requireAuth);
|
|
1137
|
+
/** How long an out-of-band wake turn waits for a busy session before giving up (see runTurn). */
|
|
1138
|
+
const WAKE_LOCK_WAIT_MS = 5 * 60_000;
|
|
1137
1139
|
initPolls({
|
|
1138
1140
|
broadcast,
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
+
// The out-of-band turn runner (polls + background-job follow-ups). It TAKES THE SESSION LOCK, and
|
|
1142
|
+
// that is load-bearing: `streamChat` never acquires one itself, so before this every guard written
|
|
1143
|
+
// against `isSessionLocked` — including background-jobs.ts's "never start a turn on top of a live
|
|
1144
|
+
// one" — was reading a lock that this path never took. Two jobs finishing minutes apart then ran
|
|
1145
|
+
// two concurrent wake turns in one session, each free to dispatch the next leg of the same
|
|
1146
|
+
// workflow: two writers on one browser instance, which is the failure these workflows are built to
|
|
1147
|
+
// prevent. Waiting (bounded) rather than failing fast, because the whole point of a wake is that
|
|
1148
|
+
// the outcome gets told: a busy session usually means another wake is mid-turn and will be done in
|
|
1149
|
+
// seconds.
|
|
1150
|
+
runTurn: async ({ prompt, sessionId, uid, userEmail }) => {
|
|
1151
|
+
const abortController = new AbortController();
|
|
1152
|
+
const deadline = Date.now() + WAKE_LOCK_WAIT_MS;
|
|
1153
|
+
while (!acquireSessionLock(sessionId, 'api', abortController)) {
|
|
1154
|
+
if (Date.now() >= deadline) {
|
|
1155
|
+
// Give up by returning NOTHING, never by throwing. A throw propagates out of wake.ts's
|
|
1156
|
+
// unguarded `await runTurn` into background-jobs' catch, which records `reported: 'failed'`
|
|
1157
|
+
// — the one delivery path with no raw fallback, so the job's outcome would reach the user in
|
|
1158
|
+
// no form at all, after wake.ts had already appended the trigger prompt (a question with no
|
|
1159
|
+
// answer in the transcript). Empty blocks are the 'no-output' contract callers already
|
|
1160
|
+
// handle: the job store then delivers its raw report instead. Degraded, but never silent.
|
|
1161
|
+
console.warn(`[wake] session ${sessionId} stayed busy for ${Math.round(WAKE_LOCK_WAIT_MS / 1000)}s — skipping the turn; the caller falls back to a raw report`);
|
|
1162
|
+
return [];
|
|
1163
|
+
}
|
|
1164
|
+
await new Promise((r) => setTimeout(r, 2_000));
|
|
1165
|
+
}
|
|
1166
|
+
try {
|
|
1167
|
+
return await consumeStream(streamChat({ prompt, sessionId, uid, userEmail, mcpServers: getMcpConfig(uid), abortController, onPermissionRequest: async () => ({ allow: true }) }));
|
|
1168
|
+
} finally {
|
|
1169
|
+
if (releaseSessionLock(sessionId, abortController)) setRunStatus(sessionId, 'idle');
|
|
1170
|
+
}
|
|
1171
|
+
},
|
|
1141
1172
|
});
|
|
1142
1173
|
// Background jobs outlive the turn that started them, so their follow-up must too. Must run AFTER
|
|
1143
1174
|
// initPolls (which wires wake.ts's turn runner) — boot adoption can report a job that finished
|
package/src/server/polls.ts
CHANGED
|
@@ -9,7 +9,7 @@ import path from 'node:path';
|
|
|
9
9
|
import { dataPath } from './paths.ts';
|
|
10
10
|
import { getSession } from './sessions.ts';
|
|
11
11
|
import { slackPost, getUserName, buildPollBlocks, type PollSpec } from './slack/api.ts';
|
|
12
|
-
import { initWake, wakeSession, type TurnRunner } from './wake.ts';
|
|
12
|
+
import { initWake, wakeSession, deliverToSession, type TurnRunner } from './wake.ts';
|
|
13
13
|
|
|
14
14
|
const PREFIX = '[polls]';
|
|
15
15
|
|
|
@@ -155,5 +155,18 @@ async function wakeAgent(p: PollRecord, reason: string): Promise<void> {
|
|
|
155
155
|
const headline = p.kind === 'question' ? 'Your question was answered' : `Your poll closed (${reason})`;
|
|
156
156
|
const prompt = `[Poll result] ${headline}. Title: "${p.title}". ${voterCount(p)} participant(s).\n${lines}\n\nFollow up appropriately (summarize, take the next action, or notify the relevant people). Do not re-post the poll.`;
|
|
157
157
|
|
|
158
|
-
|
|
158
|
+
// The return value is NOT decoration: a wake that could not run a turn ('no-output' — no runner
|
|
159
|
+
// wired, or the session stayed busy past the wake lock's wait) leaves this poll CLOSED, already
|
|
160
|
+
// re-rendered as closed in Slack, and the transcript holding a `[Poll result]` prompt with no
|
|
161
|
+
// answer. The tally would then be lost for good (the record is pruned after 7 days). So when no
|
|
162
|
+
// turn ran, deliver the lines we already built — the same raw-report fallback the background-job
|
|
163
|
+
// caller makes. `unreadFallback` above cannot cover this: wake.ts returns before it consults it.
|
|
164
|
+
const outcome = await wakeSession({ sessionId: p.sessionId, uid: p.uid, userEmail: p.userEmail, prompt, channel: 'poll', title: p.title, unreadFallback: 'Poll closed' });
|
|
165
|
+
if (outcome !== 'woke') {
|
|
166
|
+
console.warn(`${PREFIX} wake for ${p.pollId} returned '${outcome}' — delivering the tally as plain text instead`);
|
|
167
|
+
await deliverToSession({
|
|
168
|
+
sessionId: p.sessionId, uid: p.uid, title: p.title,
|
|
169
|
+
text: `${headline} — "${p.title}" (${voterCount(p)} participant(s)).\n${lines}`,
|
|
170
|
+
}).catch((e) => console.error(`${PREFIX} raw tally deliver failed:`, (e as Error)?.message));
|
|
171
|
+
}
|
|
159
172
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// A scheduled run's DECLARED outcome — the run saying what actually happened, instead of the
|
|
2
|
+
// scheduler inferring success from "the agent's turn returned".
|
|
3
|
+
//
|
|
4
|
+
// The bug this exists for: a prompt run whose real work failed (or never started) still records
|
|
5
|
+
// `ok`, because the only thing measured was that the turn came back. On 2026-08-28 the 15:30 social
|
|
6
|
+
// run's scout died, nothing was delivered, the run stored `ok`, and the (enabled) failure notifier
|
|
7
|
+
// stayed silent all day — it is event-driven on `status: 'error'` and was never given one.
|
|
8
|
+
//
|
|
9
|
+
// It gets worse with background jobs (server/background-jobs.ts): there, ending the turn early is
|
|
10
|
+
// the CORRECT behaviour — the work outlives it and the job store wakes the session when it exits.
|
|
11
|
+
// So "the turn returned" stops being even a weak proxy for the run's outcome.
|
|
12
|
+
//
|
|
13
|
+
// Shape: one JSON file per run session, written by the run itself (any tool that can write a file —
|
|
14
|
+
// no new tool surface, nothing to plumb through the engine), read by runner.ts when the turn ends.
|
|
15
|
+
// { "status": "ok" } → the run delivered
|
|
16
|
+
// { "status": "error", "error": "…" } → it did not; this fires the notifier
|
|
17
|
+
// { "status": "pending", "deadline": <epoch ms|ISO> } → work is still in flight; the run stays
|
|
18
|
+
// open until a terminal declaration lands,
|
|
19
|
+
// and FAILS if the deadline passes first
|
|
20
|
+
// Absent file ⇒ unchanged legacy behaviour (turn returned = ok), so no existing schedule changes.
|
|
21
|
+
// Deliberately domain-free: it knows nothing about what the run was doing.
|
|
22
|
+
import { mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { dataPath } from '../paths.ts';
|
|
25
|
+
|
|
26
|
+
/** Cap on how long a `pending` run may hold the window open, whatever deadline it asked for. */
|
|
27
|
+
export const MAX_PENDING_MS = 6 * 60 * 60_000;
|
|
28
|
+
/** Used when a `pending` declaration names no deadline. */
|
|
29
|
+
export const DEFAULT_PENDING_MS = 60 * 60_000;
|
|
30
|
+
|
|
31
|
+
export interface DeclaredOutcome {
|
|
32
|
+
status: 'ok' | 'error' | 'pending';
|
|
33
|
+
error?: string;
|
|
34
|
+
/** For `pending`: when the run gives up and is recorded as failed. Epoch ms or ISO-8601. */
|
|
35
|
+
deadline?: number | string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const dir = (): string => { const d = dataPath('scheduler', 'outcomes'); mkdirSync(d, { recursive: true }); return d; };
|
|
39
|
+
/** Session ids are server-minted (`sched-<id>-<ts>`), but this value reaches `path.join` and `rmSync`
|
|
40
|
+
* — so it is validated rather than trusted. A `../` in there would delete outside the outcomes dir. */
|
|
41
|
+
const safeId = (sessionId: string): string => {
|
|
42
|
+
if (!/^[A-Za-z0-9._-]+$/.test(sessionId) || sessionId.startsWith('.')) throw new Error(`unsafe session id for an outcome file: ${sessionId}`);
|
|
43
|
+
return sessionId;
|
|
44
|
+
};
|
|
45
|
+
export const outcomeFile = (sessionId: string): string => path.join(dir(), `${safeId(sessionId)}.json`);
|
|
46
|
+
|
|
47
|
+
export function readOutcome(sessionId: string): DeclaredOutcome | null {
|
|
48
|
+
let raw: string;
|
|
49
|
+
try { raw = readFileSync(outcomeFile(sessionId), 'utf-8'); } catch { return null; }
|
|
50
|
+
let o: DeclaredOutcome;
|
|
51
|
+
// A malformed declaration is not "no declaration": the run tried to say something. Surfacing it
|
|
52
|
+
// as an error beats silently falling back to the optimistic default this module exists to remove.
|
|
53
|
+
// But a plain `Write` is not atomic, so a read can also land MID-write — that is a torn read, not
|
|
54
|
+
// a malformed declaration, and failing a run for it would be the same class of lie in reverse.
|
|
55
|
+
// Re-read once after a beat before believing it (the prompt also asks for write-temp-then-rename).
|
|
56
|
+
try { o = JSON.parse(raw) as DeclaredOutcome; }
|
|
57
|
+
catch {
|
|
58
|
+
try { raw = readFileSync(outcomeFile(sessionId), 'utf-8'); o = JSON.parse(raw) as DeclaredOutcome; }
|
|
59
|
+
catch { return { status: 'error', error: `run outcome file is not valid JSON: ${raw.slice(0, 200)}` }; }
|
|
60
|
+
}
|
|
61
|
+
if (o?.status !== 'ok' && o?.status !== 'error' && o?.status !== 'pending')
|
|
62
|
+
return { status: 'error', error: `run outcome file has an invalid status: ${JSON.stringify(o?.status)}` };
|
|
63
|
+
return o;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function clearOutcome(sessionId: string): void {
|
|
67
|
+
try { rmSync(outcomeFile(sessionId)); } catch { /* nothing to clear */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Test/host seam — writes a declaration the way a run's own file write would. */
|
|
71
|
+
export function writeOutcome(sessionId: string, o: DeclaredOutcome): void {
|
|
72
|
+
writeFileSync(outcomeFile(sessionId), JSON.stringify(o));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Absolute epoch ms a `pending` run expires at, clamped to MAX_PENDING_MS. */
|
|
76
|
+
export function pendingDeadline(o: DeclaredOutcome, now: number): number {
|
|
77
|
+
const raw = typeof o.deadline === 'string' ? Date.parse(o.deadline) : o.deadline;
|
|
78
|
+
const asked = Number.isFinite(raw) ? (raw as number) : now + DEFAULT_PENDING_MS;
|
|
79
|
+
return Math.min(Math.max(asked, now), now + MAX_PENDING_MS);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The contract, appended to a scheduled prompt run so a run can report itself truthfully. */
|
|
83
|
+
export function outcomePrompt(sessionId: string): string {
|
|
84
|
+
return `# Reporting this run's outcome
|
|
85
|
+
This is a scheduled run. Unless you say otherwise, it is recorded as SUCCESSFUL the moment your turn returns — which is a lie whenever the work failed, was skipped, or is still in flight. Correct that by writing this file:
|
|
86
|
+
\`${outcomeFile(sessionId)}\`
|
|
87
|
+
- \`{"status":"ok"}\` — the run delivered what it was for.
|
|
88
|
+
- \`{"status":"error","error":"<what went wrong>"}\` — it did not. This is what raises the failure alert; write it for a leg that never ran, a worker that died, or work you could not finish.
|
|
89
|
+
- \`{"status":"pending","deadline":"<ISO-8601>"}\` — work you started outlives this turn (e.g. a background job). The run stays open and NOT successful until you write a terminal status from a later turn; if the deadline passes with no terminal status, the run is recorded as failed automatically.
|
|
90
|
+
Write it atomically — write a temp file next to it and \`mv\` it into place — so a reader can never catch it half-written.
|
|
91
|
+
Declare \`pending\` BEFORE you end a turn that leaves work running, and re-declare \`ok\`/\`error\` from the turn that sees it finish. Never declare \`ok\` for a run that did not deliver.`;
|
|
92
|
+
}
|
|
@@ -6,7 +6,8 @@ import { streamChat, type PermissionHandler } from '../claude.ts';
|
|
|
6
6
|
import { getMcpConfig } from '../mcp.ts';
|
|
7
7
|
import { appendMessage, createScheduledSession, updateScheduledSessionStatus, setRunStatus, registerLivePartial, unregisterLivePartial, writePartial, clearPartial, acquireSessionLock, releaseSessionLock, type ConvBlock } from '../sessions.ts';
|
|
8
8
|
import type { Schedule, ScheduleRunSummary } from './types.ts';
|
|
9
|
-
import { updateRunLockPid, clearRunningMarker } from './storage.ts';
|
|
9
|
+
import { updateRunLockPid, clearRunningMarker, loadSchedules } from './storage.ts';
|
|
10
|
+
import { readOutcome, clearOutcome, pendingDeadline, outcomePrompt, MAX_PENDING_MS } from './outcome.ts';
|
|
10
11
|
import { addUnread } from '../unread.ts';
|
|
11
12
|
|
|
12
13
|
export interface RunContext {
|
|
@@ -87,6 +88,70 @@ const sleep = (ms: number, signal?: AbortSignal) => new Promise<void>((resolve)
|
|
|
87
88
|
signal?.addEventListener('abort', done, { once: true });
|
|
88
89
|
});
|
|
89
90
|
|
|
91
|
+
/** How often a `pending` run is re-checked while it waits for its terminal declaration. */
|
|
92
|
+
const OUTCOME_POLL_MS = 10_000;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolve what the run itself declared, once its turn has ended.
|
|
96
|
+
*
|
|
97
|
+
* Returns null when the run declared nothing — that is the legacy path and stays exactly as it was
|
|
98
|
+
* (turn returned ⇒ ok). A `pending` declaration keeps the run open until a terminal one lands; each
|
|
99
|
+
* fresh `pending` extends the wait (leg 2 re-declaring after leg 1 finished), bounded absolutely by
|
|
100
|
+
* MAX_PENDING_MS from the first one so an agent cannot extend forever. Silence past the deadline is
|
|
101
|
+
* a FAILURE — that is the whole point: a run that never came back must alert, not read as success.
|
|
102
|
+
*/
|
|
103
|
+
/**
|
|
104
|
+
* The schedule's CURRENT next fire time, read live from disk each time it is needed.
|
|
105
|
+
*
|
|
106
|
+
* Deliberately not the caller's snapshot: `fireDue()` starts the run in its first loop and only
|
|
107
|
+
* advances `nextRun` in its second, and `startRun` deep-copies the schedule BEFORE that advance —
|
|
108
|
+
* so the snapshot this run was handed still carries the window it is running FOR (already in the
|
|
109
|
+
* past), which would make the ceiling below `Infinity` and inert. The engine persists the advanced
|
|
110
|
+
* value (`saveSchedules` at the end of `fireDue`), so disk is the source of truth here. Reading it
|
|
111
|
+
* per poll also picks up an edit made while the run waits.
|
|
112
|
+
*/
|
|
113
|
+
function liveNextWindow(scheduleId: string): number | undefined {
|
|
114
|
+
try { return loadSchedules().find((s) => s.id === scheduleId)?.nextRun; }
|
|
115
|
+
catch { return undefined; }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function resolveDeclaredOutcome(
|
|
119
|
+
sessionId: string,
|
|
120
|
+
ac: AbortController,
|
|
121
|
+
/** Schedule whose next window caps the wait — looked up live, never from the run's snapshot. */
|
|
122
|
+
scheduleId: string,
|
|
123
|
+
): Promise<{ status: Exclude<ScheduleRunSummary['status'], 'running'>; error?: string } | null> {
|
|
124
|
+
let declared = readOutcome(sessionId);
|
|
125
|
+
if (!declared) return null;
|
|
126
|
+
// Waiting happens INSIDE the run promise, and engine.startRun only does `state.running.delete()`
|
|
127
|
+
// when that promise settles — so a pending run keeps its schedule marked running, and the next
|
|
128
|
+
// fire of the same schedule is QUEUED behind it rather than run on time. Verified in
|
|
129
|
+
// engine.ts (`state.running.delete` sits in `.finally`, and `fire()` queues when `state.running`
|
|
130
|
+
// has the id). For a 3×-daily schedule a multi-hour pending wait would therefore eat the next
|
|
131
|
+
// slot. So the wait ends at the next window at the latest: the run is then recorded as failed
|
|
132
|
+
// (the notifier fires) and the new window starts clean and on time.
|
|
133
|
+
const windowStop = (): number => {
|
|
134
|
+
const next = liveNextWindow(scheduleId);
|
|
135
|
+
return next && next > Date.now() ? next - 60_000 : Infinity;
|
|
136
|
+
};
|
|
137
|
+
const absoluteStop = Date.now() + MAX_PENDING_MS;
|
|
138
|
+
while (declared?.status === 'pending' && !ac.signal.aborted) {
|
|
139
|
+
const stop = windowStop();
|
|
140
|
+
const deadline = Math.min(pendingDeadline(declared, Date.now()), absoluteStop, stop);
|
|
141
|
+
if (Date.now() >= deadline) {
|
|
142
|
+
const why = deadline === stop ? 'its next scheduled window arrived first' : `deadline ${new Date(deadline).toISOString()}`;
|
|
143
|
+
return { status: 'error', error: `Run declared itself still in flight and never reported a terminal outcome (${why}).` };
|
|
144
|
+
}
|
|
145
|
+
await sleep(Math.min(OUTCOME_POLL_MS, deadline - Date.now()), ac.signal);
|
|
146
|
+
declared = readOutcome(sessionId);
|
|
147
|
+
}
|
|
148
|
+
if (ac.signal.aborted) return { status: 'aborted' };
|
|
149
|
+
if (!declared) return { status: 'error', error: 'Run outcome declaration disappeared before it reported a terminal state.' };
|
|
150
|
+
return declared.status === 'error'
|
|
151
|
+
? { status: 'error', error: declared.error ? `Run reported failure: ${declared.error}` : 'Run reported failure with no detail.' }
|
|
152
|
+
: { status: 'ok' };
|
|
153
|
+
}
|
|
154
|
+
|
|
90
155
|
function formatEventBlock(e: EventContext): string {
|
|
91
156
|
let body: string;
|
|
92
157
|
try { body = JSON.stringify(e.payload, null, 2); } catch { body = String(e.payload); }
|
|
@@ -140,6 +205,17 @@ export async function runSchedule(
|
|
|
140
205
|
if (eventCtx) base = `${base}\n\n---\n${formatEventBlock(eventCtx)}`;
|
|
141
206
|
prompt = base;
|
|
142
207
|
}
|
|
208
|
+
// Tell the run how to report its own truthful outcome (scheduler/outcome.ts). Prompt tasks only:
|
|
209
|
+
// a `bash` task's permission handler allows nothing but the task's own command, so such a run
|
|
210
|
+
// could not write the file even if it wanted to — its exit code is already the truth there.
|
|
211
|
+
if (task.kind === 'prompt') {
|
|
212
|
+
// Cleared on RESUME too: a resume reuses the interrupted run's session id, so a declaration left
|
|
213
|
+
// by the attempt that crashed would be adopted as this attempt's verdict. The contract is
|
|
214
|
+
// re-stated for the same reason — the resumed turn must be able to declare for itself.
|
|
215
|
+
clearOutcome(sessionId);
|
|
216
|
+
prompt = `${prompt}\n\n---\n${outcomePrompt(sessionId)}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
143
219
|
// task.engine/task.model ride the same prompt-directive channel users type by hand —
|
|
144
220
|
// parseDirectives strips them and resolves aliases. Prepending (vs new plumbing) also persists the
|
|
145
221
|
// choice into the saved prompt, so the session UI shows what the schedule actually requested.
|
|
@@ -322,6 +398,27 @@ export async function runSchedule(
|
|
|
322
398
|
updateScheduledSessionStatus(sessionId, status);
|
|
323
399
|
}
|
|
324
400
|
|
|
401
|
+
// The run's OWN verdict beats "the turn returned" — see scheduler/outcome.ts. Deliberately after
|
|
402
|
+
// the finally: the session lock is released by now, so a run that declared `pending` can be closed
|
|
403
|
+
// by a later turn in this session (a background job's wake, a follow-up message) while we wait.
|
|
404
|
+
if (status === 'ok' && task.kind === 'prompt') {
|
|
405
|
+
const declared = await resolveDeclaredOutcome(sessionId, abortController, schedule.id);
|
|
406
|
+
if (declared) {
|
|
407
|
+
status = declared.status;
|
|
408
|
+
error = declared.error;
|
|
409
|
+
updateScheduledSessionStatus(sessionId, status);
|
|
410
|
+
if (status !== 'ok') {
|
|
411
|
+
appendMessage(sessionId, {
|
|
412
|
+
id: crypto.randomUUID(),
|
|
413
|
+
role: 'assistant',
|
|
414
|
+
blocks: [{ type: 'error', text: error ?? `Run reported status ${status}` }],
|
|
415
|
+
});
|
|
416
|
+
onEvent({ type: 'session_messages_changed', sessionId });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
clearOutcome(sessionId);
|
|
420
|
+
}
|
|
421
|
+
|
|
325
422
|
const preview = assistantText.slice(0, 120) || (status === 'ok' ? 'Schedule completed' : `Schedule ${status}`);
|
|
326
423
|
addUnread(schedule.createdBy.uid, sessionId, preview, 'schedule', schedule.name);
|
|
327
424
|
|