@timqi/pier 0.0.28 → 0.1.0
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/dist/agent/config-sync.js +12 -1
- package/dist/agent/config.js +53 -23
- package/dist/agent/events.js +27 -4
- package/dist/agent/listing.js +114 -16
- package/dist/agent/pi.js +158 -16
- package/dist/channels/lark-panel.js +1 -1
- package/dist/core/inbox.js +1 -1
- package/dist/core/router.js +14 -0
- package/dist/core/types.js +13 -1
- package/dist/db.js +48 -0
- package/dist/drain.js +1 -1
- package/dist/extensions/web/content.js +2 -2
- package/dist/main.js +8 -1
- package/dist/secrets.js +1 -1
- package/dist/service.js +2 -2
- package/dist/settings.js +31 -10
- package/dist/tasks/agent.js +109 -73
- package/dist/tasks/callbacks.js +1 -3
- package/dist/tasks/command.js +27 -8
- package/dist/tasks/execution.js +6 -4
- package/dist/tasks/groups.js +35 -26
- package/dist/tasks/messages.js +33 -27
- package/dist/tasks/outbox.js +48 -21
- package/dist/tasks/routes.js +1 -4
- package/dist/tasks/runs.js +10 -4
- package/dist/tasks/service.js +24 -12
- package/dist/tasks/store.js +17 -3
- package/dist/tasks/tool.js +97 -25
- package/dist/tools.js +2 -2
- package/dist/update.js +1 -1
- package/dist/web/instance.js +10 -3
- package/dist/web/providers.js +9 -2
- package/dist/web/public/assets/{activity-DVamP8fs.js → activity-Bl3vZukb.js} +2 -2
- package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
- package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js +1 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js +4 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js +85 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css +2 -0
- package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js +1 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js +5 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js +3 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js +4 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
- package/dist/web/public/index.html +103 -134
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest +2 -2
- package/dist/web/public/manifest.webmanifest.br +0 -0
- package/dist/web/public/manifest.webmanifest.gz +0 -0
- package/dist/web/server.js +55 -36
- package/dist/web/session-state.js +54 -33
- package/package.json +2 -1
- package/skills/pier-tasks/SKILL.md +146 -127
- package/dist/web/public/assets/activity-DVamP8fs.js.br +0 -0
- package/dist/web/public/assets/activity-DVamP8fs.js.gz +0 -0
- package/dist/web/public/assets/boards-BKtjPwus.js +0 -1
- package/dist/web/public/assets/boards-BKtjPwus.js.br +0 -0
- package/dist/web/public/assets/boards-BKtjPwus.js.gz +0 -0
- package/dist/web/public/assets/explorer-CjO9NAKQ.js +0 -4
- package/dist/web/public/assets/explorer-CjO9NAKQ.js.br +0 -0
- package/dist/web/public/assets/explorer-CjO9NAKQ.js.gz +0 -0
- package/dist/web/public/assets/index-C23sXyS3.css +0 -2
- package/dist/web/public/assets/index-C23sXyS3.css.br +0 -0
- package/dist/web/public/assets/index-C23sXyS3.css.gz +0 -0
- package/dist/web/public/assets/index-DzWnvhXb.js +0 -85
- package/dist/web/public/assets/index-DzWnvhXb.js.br +0 -0
- package/dist/web/public/assets/index-DzWnvhXb.js.gz +0 -0
- package/dist/web/public/assets/runs-CxIZAx1s.js +0 -1
- package/dist/web/public/assets/runs-CxIZAx1s.js.br +0 -0
- package/dist/web/public/assets/runs-CxIZAx1s.js.gz +0 -0
- package/dist/web/public/assets/settings-B8JWLbpL.js +0 -5
- package/dist/web/public/assets/settings-B8JWLbpL.js.br +0 -0
- package/dist/web/public/assets/settings-B8JWLbpL.js.gz +0 -0
- package/dist/web/public/assets/task-runs-BZ1brkLC.js +0 -3
- package/dist/web/public/assets/task-runs-BZ1brkLC.js.br +0 -0
- package/dist/web/public/assets/task-runs-BZ1brkLC.js.gz +0 -0
- package/dist/web/public/assets/tasks-BoYiBQpq.js +0 -4
- package/dist/web/public/assets/tasks-BoYiBQpq.js.br +0 -0
- package/dist/web/public/assets/tasks-BoYiBQpq.js.gz +0 -0
package/dist/tasks/outbox.js
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
// this logic was written twice and the two copies drifted — the ceiling was
|
|
4
4
|
// checked in one order here and another there, and only one of them counted a
|
|
5
5
|
// failed attempt.
|
|
6
|
-
import { Router } from "../core/router.js";
|
|
7
6
|
import { logger } from "../log.js";
|
|
8
7
|
import { MAX_DELIVERY_ATTEMPTS, retryDelay, undeliverable } from "./types.js";
|
|
9
8
|
const log = logger("tasks");
|
|
9
|
+
/** The records a delivery names, from either side of the seam: a batch names
|
|
10
|
+
* every one of them, a group names itself. */
|
|
11
|
+
const callbackIds = (origin) => origin?.kind === "task-callback" ? origin.runIds ?? [origin.runId] : [];
|
|
10
12
|
export class Outbox {
|
|
11
13
|
router;
|
|
12
14
|
kind;
|
|
@@ -35,6 +37,7 @@ export class Outbox {
|
|
|
35
37
|
return;
|
|
36
38
|
for (const record of mine)
|
|
37
39
|
this.delivering.add(this.kind.id(record));
|
|
40
|
+
const counted = new Set();
|
|
38
41
|
try {
|
|
39
42
|
const session = await this.router.ensure({ channelId: "task", conversationId: sessionId });
|
|
40
43
|
// The transcript read is both the crash-window dedupe and the proof.
|
|
@@ -46,40 +49,64 @@ export class Outbox {
|
|
|
46
49
|
return;
|
|
47
50
|
// Waiting for a busy target is not a delivery attempt: counting it would
|
|
48
51
|
// inflate the attempts once per second and skip the failure backoff
|
|
49
|
-
// straight to its ceiling.
|
|
50
|
-
|
|
52
|
+
// straight to its ceiling. A record delegated with `steer` is the
|
|
53
|
+
// exception it asked for — it joins the running turn instead, and the
|
|
54
|
+
// rest of the batch keeps waiting for the turn to end. But only once:
|
|
55
|
+
// handed over, a steer sits in Pi's in-memory queue, invisible in the
|
|
56
|
+
// transcript until the turn drains it, so the queue is the second place
|
|
57
|
+
// this has to look before deciding nothing arrived (messages.ts:340).
|
|
58
|
+
const streaming = session.state === "streaming";
|
|
59
|
+
let sending = live;
|
|
60
|
+
if (streaming) {
|
|
61
|
+
const handedOver = await this.queued(session);
|
|
62
|
+
const steerNow = (record) => record.callbackMode === "steer" && !handedOver.has(this.kind.id(record));
|
|
63
|
+
sending = live.filter(steerNow);
|
|
51
64
|
for (const record of live)
|
|
52
|
-
|
|
53
|
-
|
|
65
|
+
if (!steerNow(record))
|
|
66
|
+
this.defer(record);
|
|
67
|
+
if (sending.length === 0)
|
|
68
|
+
return;
|
|
54
69
|
}
|
|
55
|
-
for (const record of
|
|
70
|
+
for (const record of sending) {
|
|
56
71
|
this.sent(record);
|
|
57
|
-
|
|
58
|
-
|
|
72
|
+
counted.add(this.kind.id(record));
|
|
73
|
+
}
|
|
74
|
+
const { text, origin } = this.kind.input(sending);
|
|
75
|
+
log.debug(`callback for ${sending.map((r) => this.kind.id(r)).join(", ")} → session ${sessionId}`);
|
|
59
76
|
// Not awaited: `systemInput` settles with the recipient's whole turn, and
|
|
60
77
|
// holding the delivery lock that long would keep the proof from ever
|
|
61
78
|
// being read — which is the only thing that marks this delivered.
|
|
62
|
-
session.systemInput(text, origin, "followUp")
|
|
63
|
-
.catch((error) => this.retry(sessionId,
|
|
79
|
+
session.systemInput(text, origin, streaming ? "steer" : "followUp")
|
|
80
|
+
.catch((error) => this.retry(sessionId, sending, error, counted));
|
|
64
81
|
// Pi records the input as it starts the turn, so the proof is usually
|
|
65
82
|
// here already; the tick sweep is the backstop when it is not.
|
|
66
|
-
await this.settle(
|
|
83
|
+
await this.settle(sending, session);
|
|
67
84
|
}
|
|
68
85
|
catch (error) {
|
|
69
|
-
this.retry(sessionId, mine, error);
|
|
86
|
+
this.retry(sessionId, mine, error, counted);
|
|
70
87
|
}
|
|
71
88
|
finally {
|
|
72
89
|
for (const record of mine)
|
|
73
90
|
this.delivering.delete(this.kind.id(record));
|
|
74
91
|
}
|
|
75
92
|
}
|
|
93
|
+
/** Handed over and waiting in the recipient's queue for the running turn to
|
|
94
|
+
* drain it — what the transcript cannot answer yet. Empty unless the
|
|
95
|
+
* session is streaming: Pi drops the list when the turn ends. */
|
|
96
|
+
async queued(session) {
|
|
97
|
+
const ids = new Set();
|
|
98
|
+
for (const origin of await session.pendingSystemInputs())
|
|
99
|
+
for (const id of callbackIds(origin))
|
|
100
|
+
ids.add(id);
|
|
101
|
+
return ids;
|
|
102
|
+
}
|
|
76
103
|
/** Marks every record the transcript proves; returns the ones it does not. */
|
|
77
104
|
async settle(records, session) {
|
|
78
105
|
const seen = new Set();
|
|
79
106
|
for (const turn of await session.history()) {
|
|
80
|
-
if (turn.role !== "system"
|
|
107
|
+
if (turn.role !== "system")
|
|
81
108
|
continue;
|
|
82
|
-
for (const id of turn.origin
|
|
109
|
+
for (const id of callbackIds(turn.origin))
|
|
83
110
|
seen.add(id);
|
|
84
111
|
}
|
|
85
112
|
const unproven = [];
|
|
@@ -95,14 +122,15 @@ export class Outbox {
|
|
|
95
122
|
/** The recipient is waiting for an answer that is now late: the retry itself
|
|
96
123
|
* is silent, so this line is the only sign it is being retried — and the
|
|
97
124
|
* ceiling is what ends the retrying out loud. */
|
|
98
|
-
retry(sessionId, batch, error) {
|
|
125
|
+
retry(sessionId, batch, error, counted) {
|
|
99
126
|
log.warn(`callback to session ${sessionId} failed, will retry`, error);
|
|
100
127
|
for (const stale of batch) {
|
|
101
128
|
const record = this.kind.reload(this.kind.id(stale));
|
|
102
129
|
// Already proven delivered, or already given up on: not a failure.
|
|
103
130
|
if (!record || (record.callbackState !== "pending" && record.callbackState !== "failed"))
|
|
104
131
|
continue;
|
|
105
|
-
this.failed(record, error);
|
|
132
|
+
this.failed(record, error, counted.has(this.kind.id(record)));
|
|
133
|
+
counted.add(this.kind.id(record));
|
|
106
134
|
this.spent(record, sessionId);
|
|
107
135
|
}
|
|
108
136
|
}
|
|
@@ -140,11 +168,10 @@ export class Outbox {
|
|
|
140
168
|
record.callbackNextAttemptAt = null;
|
|
141
169
|
this.write(record);
|
|
142
170
|
}
|
|
143
|
-
/**
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
record.callbackAttempts += 1;
|
|
171
|
+
/** A failed pass costs one attempt, whether it died before or after send. */
|
|
172
|
+
failed(record, error, counted) {
|
|
173
|
+
if (!counted)
|
|
174
|
+
record.callbackAttempts += 1;
|
|
148
175
|
record.callbackState = "failed";
|
|
149
176
|
record.callbackError = String(error);
|
|
150
177
|
record.callbackNextAttemptAt = Date.now() + retryDelay(record.callbackAttempts);
|
package/dist/tasks/routes.js
CHANGED
|
@@ -51,10 +51,7 @@ export function registerTaskRoutes(app, tasks, activity) {
|
|
|
51
51
|
const now = Date.now();
|
|
52
52
|
const recent = c.req.query("scope") === "recent";
|
|
53
53
|
const windowStart = now - 24 * 60 * 60 * 1000;
|
|
54
|
-
|
|
55
|
-
// last 24h no matter when it was queued, so it never drops a live run.
|
|
56
|
-
const runs = tasks.recentRuns(200).filter((run) => run.state === "queued" || run.state === "running" ||
|
|
57
|
-
(recent && run.queuedAt >= windowStart));
|
|
54
|
+
const runs = tasks.activityRuns(recent ? windowStart : undefined);
|
|
58
55
|
const listed = await activity.factory.list();
|
|
59
56
|
const byId = new Map(listed.map((session) => [session.id, session]));
|
|
60
57
|
const linkedIds = new Set();
|
package/dist/tasks/runs.js
CHANGED
|
@@ -4,9 +4,7 @@
|
|
|
4
4
|
// exploding (depth, children per root) are decided here, once, because every
|
|
5
5
|
// caller — scheduler, tool, HTTP — enqueues through this one door.
|
|
6
6
|
import { logger } from "../log.js";
|
|
7
|
-
import { TaskCallbacks } from "./callbacks.js";
|
|
8
7
|
import { newId } from "./definitions.js";
|
|
9
|
-
import { TaskStore } from "./store.js";
|
|
10
8
|
const log = logger("tasks");
|
|
11
9
|
const MAX_DEPTH = 2;
|
|
12
10
|
const MAX_CHILDREN_PER_ROOT = 16;
|
|
@@ -23,7 +21,8 @@ export class TaskRunQueue {
|
|
|
23
21
|
this.execute = execute;
|
|
24
22
|
this.changed = changed;
|
|
25
23
|
}
|
|
26
|
-
|
|
24
|
+
/** Validate and persist only; callers may stage several runs in a transaction. */
|
|
25
|
+
prepare(definition, input, source, parentRunId, provenance) {
|
|
27
26
|
const id = newId();
|
|
28
27
|
const parent = parentRunId ? this.getRun(parentRunId) : null;
|
|
29
28
|
const depth = provenance.depth ?? (parent ? parent.depth + 1 : 0);
|
|
@@ -63,6 +62,7 @@ export class TaskRunQueue {
|
|
|
63
62
|
targetSessionId,
|
|
64
63
|
sessionMode,
|
|
65
64
|
callbackSessionId,
|
|
65
|
+
...(provenance.callbackMode === "steer" ? { callbackMode: "steer" } : {}),
|
|
66
66
|
background: provenance.background ?? false,
|
|
67
67
|
callbackState: overlapped && callbackSessionId ? "pending" : null,
|
|
68
68
|
callbackAttempts: 0,
|
|
@@ -84,7 +84,14 @@ export class TaskRunQueue {
|
|
|
84
84
|
finishedAt: overlapped ? now : null,
|
|
85
85
|
};
|
|
86
86
|
this.store.saveRun(run);
|
|
87
|
+
return run;
|
|
88
|
+
}
|
|
89
|
+
/** Publish and execute only after all records this run relies on committed. */
|
|
90
|
+
start(run) {
|
|
87
91
|
this.changed(run);
|
|
92
|
+
const { id, depth, triggerSource: source } = run;
|
|
93
|
+
const overlapped = run.skipReason === "overlap";
|
|
94
|
+
const definition = run.context.definition;
|
|
88
95
|
// Why a run exists is the first question asked of a surprising one, and it
|
|
89
96
|
// is answerable only here: the row keeps the ids, not the reason. A watch
|
|
90
97
|
// probe queues on every interval and mostly matches nothing, so it says so
|
|
@@ -99,6 +106,5 @@ export class TaskRunQueue {
|
|
|
99
106
|
this.execute(run);
|
|
100
107
|
else if (run.callbackState === "pending")
|
|
101
108
|
void this.callbacks.deliver(run);
|
|
102
|
-
return run;
|
|
103
109
|
}
|
|
104
110
|
}
|
package/dist/tasks/service.js
CHANGED
|
@@ -4,8 +4,6 @@
|
|
|
4
4
|
// like it makes belongs to a file beside it (definitions, runs, execution,
|
|
5
5
|
// groups, messages, callbacks) — what is genuinely here is scheduling and the
|
|
6
6
|
// facade, so the HTTP routes and the task tool cannot drift apart.
|
|
7
|
-
import { EventHub } from "../core/hub.js";
|
|
8
|
-
import { Router } from "../core/router.js";
|
|
9
7
|
import { logger } from "../log.js";
|
|
10
8
|
import { AgentTaskRunner } from "./agent.js";
|
|
11
9
|
import { TaskCallbacks } from "./callbacks.js";
|
|
@@ -14,7 +12,6 @@ import { TaskExecution } from "./execution.js";
|
|
|
14
12
|
import { TaskGroups } from "./groups.js";
|
|
15
13
|
import { TaskMessenger } from "./messages.js";
|
|
16
14
|
import { TaskRunQueue } from "./runs.js";
|
|
17
|
-
import { TaskStore } from "./store.js";
|
|
18
15
|
import { handleTaskTool } from "./tool.js";
|
|
19
16
|
import { isTerminal } from "./types.js";
|
|
20
17
|
const log = logger("tasks");
|
|
@@ -52,20 +49,21 @@ export class TaskService {
|
|
|
52
49
|
this.hub = hub;
|
|
53
50
|
this.instance = instance;
|
|
54
51
|
const unreachable = (sessionId, what, why) => this.unreachable(sessionId, what, why);
|
|
55
|
-
this.messages = new TaskMessenger(store, router, hub, (runId, prompt, fromSessionId) => this.
|
|
52
|
+
this.messages = new TaskMessenger(store, router, hub, (runId, prompt, fromSessionId) => this.prepareResume(runId, prompt, { invokedBySessionId: fromSessionId, callbackSessionId: fromSessionId, background: true }), unreachable, (run) => this.runs.start(run));
|
|
56
53
|
this.definitions = new TaskDefinitions(store, factory, router, hub, instance?.systemActions);
|
|
57
54
|
this.callbacks = new TaskCallbacks(store, router, (run) => this.changed(run), unreachable);
|
|
58
55
|
this.groups = new TaskGroups(store, router, {
|
|
59
56
|
getRun: (id) => this.getRun(id),
|
|
60
57
|
cancel: (id) => { this.cancel(id); },
|
|
61
58
|
openDecisionId: (runId) => this.messages.openDecisionId(runId),
|
|
62
|
-
|
|
59
|
+
prepareMember: (taskId, groupId, callerSessionId, parentRunId) => this.prepareRun(taskId, null, "agent", parentRunId, {
|
|
63
60
|
invokedBySessionId: callerSessionId,
|
|
64
61
|
sourceSessionId: callerSessionId,
|
|
65
62
|
callbackSessionId: null,
|
|
66
63
|
background: true,
|
|
67
64
|
groupId,
|
|
68
65
|
}),
|
|
66
|
+
startMember: (run) => this.runs.start(run),
|
|
69
67
|
}, (group) => this.hub.emitWorkspace({ type: "task-group-changed", groupId: group.id }), unreachable);
|
|
70
68
|
const agent = new AgentTaskRunner(factory, router, store, this.messages, (run) => this.changed(run));
|
|
71
69
|
this.execution = new TaskExecution(store, this.definitions, this.callbacks, agent, {
|
|
@@ -198,8 +196,8 @@ export class TaskService {
|
|
|
198
196
|
queryRuns(query = {}) {
|
|
199
197
|
return this.store.queryRuns(query);
|
|
200
198
|
}
|
|
201
|
-
|
|
202
|
-
return this.store.
|
|
199
|
+
activityRuns(since) {
|
|
200
|
+
return this.store.activityRuns(since);
|
|
203
201
|
}
|
|
204
202
|
recentMessages(since) {
|
|
205
203
|
return this.messages.recent(since);
|
|
@@ -222,13 +220,18 @@ export class TaskService {
|
|
|
222
220
|
return this.store.taskOwnedSessionIds();
|
|
223
221
|
}
|
|
224
222
|
run(taskId, input = null, source = "manual", parentRunId = null, provenance = {}) {
|
|
223
|
+
const run = this.prepareRun(taskId, input, source, parentRunId, provenance);
|
|
224
|
+
this.runs.start(run);
|
|
225
|
+
return run;
|
|
226
|
+
}
|
|
227
|
+
prepareRun(taskId, input, source, parentRunId, provenance) {
|
|
225
228
|
this.refusePaused(parentRunId);
|
|
226
229
|
const task = this.get(taskId);
|
|
227
230
|
// `enabled:false` pauses scheduling only; manual and agent triggers still
|
|
228
231
|
// run a paused task on demand. Archiving is the terminal state.
|
|
229
232
|
if (task.archived)
|
|
230
233
|
throw new Error("archived tasks cannot run");
|
|
231
|
-
return this.runs.
|
|
234
|
+
return this.runs.prepare(task, input, source, parentRunId, provenance);
|
|
232
235
|
}
|
|
233
236
|
async waitForRun(id) {
|
|
234
237
|
const current = this.getRun(id);
|
|
@@ -256,9 +259,9 @@ export class TaskService {
|
|
|
256
259
|
getGroup(id) {
|
|
257
260
|
return this.groups.members(id);
|
|
258
261
|
}
|
|
259
|
-
runGroup(definitions, join, callerSessionId, parentRunId, callbackSessionId) {
|
|
262
|
+
runGroup(definitions, join, callerSessionId, parentRunId, callbackSessionId, callbackMode) {
|
|
260
263
|
this.refusePaused(parentRunId);
|
|
261
|
-
return this.groups.runAll(definitions, join, callerSessionId, parentRunId, callbackSessionId);
|
|
264
|
+
return this.groups.runAll(definitions, join, callerSessionId, parentRunId, callbackSessionId, callbackMode);
|
|
262
265
|
}
|
|
263
266
|
descendants(run) {
|
|
264
267
|
const byParent = new Map();
|
|
@@ -291,6 +294,16 @@ export class TaskService {
|
|
|
291
294
|
return this.messages.reply(messageId, fromSessionId, message);
|
|
292
295
|
}
|
|
293
296
|
resume(id, message, provenance = {}) {
|
|
297
|
+
const { run, expired } = this.store.transact(() => ({
|
|
298
|
+
run: this.prepareResume(id, message, provenance),
|
|
299
|
+
expired: this.messages.expireDecisions(id, "superseded by a manual resume"),
|
|
300
|
+
}));
|
|
301
|
+
for (const message of expired)
|
|
302
|
+
this.messages.changed(message);
|
|
303
|
+
this.runs.start(run);
|
|
304
|
+
return run;
|
|
305
|
+
}
|
|
306
|
+
prepareResume(id, message, provenance) {
|
|
294
307
|
this.refusePaused();
|
|
295
308
|
const prior = this.getRun(id);
|
|
296
309
|
if (!isTerminal(prior.state))
|
|
@@ -299,8 +312,7 @@ export class TaskService {
|
|
|
299
312
|
throw new Error("only persisted Agent runs can be resumed");
|
|
300
313
|
}
|
|
301
314
|
const prompt = requiredString(message, "message");
|
|
302
|
-
this.
|
|
303
|
-
return this.runs.enqueue(prior.context.definition, null, "agent", null, {
|
|
315
|
+
return this.runs.prepare(prior.context.definition, null, "agent", null, {
|
|
304
316
|
...provenance,
|
|
305
317
|
sourceSessionId: provenance.invokedBySessionId ?? prior.invokedBySessionId,
|
|
306
318
|
targetSessionId: prior.targetSessionId,
|
package/dist/tasks/store.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// runs, groups and messages are rows here, read and written through one
|
|
3
3
|
// connection db.ts opened. A store owns its queries, never its own tables or
|
|
4
4
|
// its own handle — the schema is db.ts's migration list.
|
|
5
|
-
import { pierDb, statements } from "../db.js";
|
|
5
|
+
import { pierDb, statements, transact } from "../db.js";
|
|
6
6
|
const clamp = (limit, cap) => Math.min(Math.max(limit, 1), cap);
|
|
7
7
|
export class TaskStore {
|
|
8
8
|
db;
|
|
@@ -12,6 +12,10 @@ export class TaskStore {
|
|
|
12
12
|
this.db = db;
|
|
13
13
|
this.sql = statements(db);
|
|
14
14
|
}
|
|
15
|
+
/** Commit related task records before publishing events or starting work. */
|
|
16
|
+
transact(work) {
|
|
17
|
+
return transact(this.db, work);
|
|
18
|
+
}
|
|
15
19
|
// Every table is one JSON column plus query columns; these two are the only
|
|
16
20
|
// readers, and the one seam where a future schema change normalizes old rows
|
|
17
21
|
// (pre-v1 databases are refused outright in db.ts).
|
|
@@ -101,8 +105,18 @@ export class TaskStore {
|
|
|
101
105
|
nextCursor: rows.length > limit && last ? { queuedAt: last.queuedAt, id: last.id } : null,
|
|
102
106
|
};
|
|
103
107
|
}
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
/** Activity never limits live work; only its optional history is bounded. */
|
|
109
|
+
activityRuns(since) {
|
|
110
|
+
const active = this.#many("SELECT json FROM task_runs WHERE state IN ('queued', 'running') ORDER BY queued_at DESC, id DESC");
|
|
111
|
+
if (since === undefined)
|
|
112
|
+
return active;
|
|
113
|
+
const recent = this.#many(`
|
|
114
|
+
SELECT json FROM task_runs
|
|
115
|
+
WHERE state NOT IN ('queued', 'running') AND queued_at >= ?
|
|
116
|
+
AND NOT (state = 'succeeded' AND json_extract(json, '$.matched') IS 0)
|
|
117
|
+
ORDER BY queued_at DESC, id DESC LIMIT 200
|
|
118
|
+
`, since);
|
|
119
|
+
return [...active, ...recent].sort((a, b) => b.queuedAt - a.queuedAt || b.id.localeCompare(a.id));
|
|
106
120
|
}
|
|
107
121
|
listRunsByRoot(rootRunId, limit = 100) {
|
|
108
122
|
return this.#many(`
|
package/dist/tasks/tool.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { isAbsolute, resolve } from "node:path";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { logger } from "../log.js";
|
|
4
|
+
import { record, requiredString } from "./definitions.js";
|
|
5
|
+
import { isTerminal } from "./types.js";
|
|
6
|
+
const log = logger("tasks");
|
|
6
7
|
// JSON-Schema enum emits ~1/3 the tokens of typebox's anyOf-of-consts.
|
|
7
8
|
const strEnum = (...values) => Type.Unsafe({ type: "string", enum: [...values] });
|
|
8
9
|
/**
|
|
9
10
|
* Drop the fields with nothing in them instead of sending `null`.
|
|
10
11
|
*
|
|
11
12
|
* A run summary has eighteen fields and most are empty for most of a run's
|
|
12
|
-
* life; a model reads "absent" and "null" the same way. On a
|
|
13
|
-
* several runs this is a third of the payload.
|
|
13
|
+
* life; a model reads "absent" and "null" the same way. On a group summary
|
|
14
|
+
* that lists several runs this is a third of the payload.
|
|
14
15
|
*
|
|
15
16
|
* The input names every field — a summary that forgot one would otherwise pass
|
|
16
17
|
* as "that field was empty" — and the result is the type with the empty ones
|
|
@@ -28,6 +29,7 @@ const summarize = (run, pendingDecisionId) => defined({
|
|
|
28
29
|
targetSessionId: run.targetSessionId,
|
|
29
30
|
callbackSessionId: run.callbackSessionId,
|
|
30
31
|
callbackState: run.callbackState,
|
|
32
|
+
callbackMode: run.callbackMode ?? null,
|
|
31
33
|
pendingDecisionId,
|
|
32
34
|
depth: run.depth,
|
|
33
35
|
queuedAt: run.queuedAt,
|
|
@@ -36,9 +38,23 @@ const summarize = (run, pendingDecisionId) => defined({
|
|
|
36
38
|
result: run.result,
|
|
37
39
|
error: run.error,
|
|
38
40
|
skipReason: run.skipReason,
|
|
41
|
+
next: null,
|
|
39
42
|
});
|
|
40
|
-
/**
|
|
41
|
-
*
|
|
43
|
+
/** What a run receipt says happens next. The callback is the whole answer, so
|
|
44
|
+
* the receipt says so — a model that just launched work otherwise reaches
|
|
45
|
+
* for a status call. `none` is stated as what it is: no delivery at all. */
|
|
46
|
+
const receipt = (summary, callbackSessionId, mode, callerSessionId) => ({
|
|
47
|
+
...summary,
|
|
48
|
+
next: callbackSessionId === null
|
|
49
|
+
? "callback none: the result is not delivered to anyone"
|
|
50
|
+
: callbackSessionId !== callerSessionId
|
|
51
|
+
? `the result is delivered to session ${callbackSessionId}; this session will not receive a callback`
|
|
52
|
+
: mode === "steer"
|
|
53
|
+
? "the result interrupts your running turn as a steer message; nothing to query"
|
|
54
|
+
: "the result arrives as a callback message once your turn ends; nothing to query",
|
|
55
|
+
});
|
|
56
|
+
/** A group echoes many results at once, so each is capped; a single-run
|
|
57
|
+
* `recover` stays whole — it is the escape hatch every truncation note points at. */
|
|
42
58
|
const trimResult = (summary) => {
|
|
43
59
|
if (summary.result?.type !== "agent" || summary.result.text.length <= 2000)
|
|
44
60
|
return summary;
|
|
@@ -46,17 +62,32 @@ const trimResult = (summary) => {
|
|
|
46
62
|
...summary,
|
|
47
63
|
result: {
|
|
48
64
|
...summary.result,
|
|
49
|
-
text: `${summary.result.text.slice(0, 2000)}\n[truncated —
|
|
65
|
+
text: `${summary.result.text.slice(0, 2000)}\n[truncated — recover run_id ${summary.runId} with a reason for the full text]`,
|
|
50
66
|
},
|
|
51
67
|
};
|
|
52
68
|
};
|
|
69
|
+
/** Whether a callback record has said its last word: the input is in the
|
|
70
|
+
* recipient's transcript, delivery was given up on and reported, or there
|
|
71
|
+
* was never one to wait for (`callback:"none"`). Anything else is still on
|
|
72
|
+
* its way, and reading the result here would be reading it twice. */
|
|
73
|
+
const settled = (callback) => callback.callbackState === null || callback.callbackState === "delivered" || callback.callbackState === "abandoned";
|
|
74
|
+
/** The one refusal `recover` gives before a result is readable. Deliberately
|
|
75
|
+
* the same words for queued, running, pending and retrying: a refusal that
|
|
76
|
+
* named the state would be the status query this operation replaced. */
|
|
77
|
+
const notRecoverable = (what, callback) => {
|
|
78
|
+
throw new Error(callback.callbackSessionId === null
|
|
79
|
+
? `${what} is not recoverable yet: it was launched with callback none, so nothing will be delivered; recover reads finished results only and cannot wait for work`
|
|
80
|
+
: `${what} is not recoverable yet: wait for automatic delivery to session ${callback.callbackSessionId}; recover cannot wait for work`);
|
|
81
|
+
};
|
|
53
82
|
const summarizeGroup = (group, members, messages) => defined({
|
|
54
83
|
groupId: group.id,
|
|
55
84
|
join: group.join,
|
|
56
85
|
state: group.finishedAt ? "finished" : "running",
|
|
57
86
|
callbackState: group.callbackState,
|
|
87
|
+
callbackMode: group.callbackMode ?? null,
|
|
58
88
|
winnerRunId: group.winnerRunId,
|
|
59
89
|
members: members.map((run) => trimResult(summarize(run, messages.openDecisionId(run.id)))),
|
|
90
|
+
next: null,
|
|
60
91
|
});
|
|
61
92
|
const LaunchSchema = Type.Object({
|
|
62
93
|
model: Type.Optional(Type.Object({ provider: Type.String(), id: Type.String() })),
|
|
@@ -103,15 +134,15 @@ export function taskToolSpec(execute) {
|
|
|
103
134
|
return {
|
|
104
135
|
name: "task",
|
|
105
136
|
label: "Pier Task",
|
|
106
|
-
description: "Manage durable Pier tasks and subagents. Agent tasks run in a fresh session or a reused one. create files a definition the operator sees in the Console — only for schedules or roles you will run again; a one-off is run with a prompt. Run executes a stored task by task_id, a one-shot subagent from a prompt (shorthand: prompt + optional cwd/launch/name — cwd defaults to your own directory, relative paths resolve against it, name comes from the prompt) or from a full inline task draft, or a core-joined fan-out via tasks[] with join all|first.
|
|
137
|
+
description: "Manage durable Pier tasks and subagents. Agent tasks run in a fresh session or a reused one. create files a definition the operator sees in the Console — only for schedules or roles you will run again; a one-off is run with a prompt. Run executes a stored task by task_id, a one-shot subagent from a prompt (shorthand: prompt + optional cwd/launch/name — cwd defaults to your own directory, relative paths resolve against it, name comes from the prompt) or from a full inline task draft, or a core-joined fan-out via tasks[] with join all|first. Every operation returns immediately: results, group joins, and decision replies arrive as callback messages once your turn ends — there is no status query; pass callback 'steer' to have a result interrupt your running turn instead, or 'none' for no callback at all. recover (run_id or group_id, plus a reason) re-reads a finished result after its callback has settled — for truncated text or lost context, never to check progress. Use steer/follow_up/resume for child control and contact/reply for supervisor decisions. models lists the deployment's model menu (operator pins with intent notes, else the live catalog).",
|
|
107
138
|
parameters: Type.Object({
|
|
108
|
-
operation: strEnum("list", "create", "update", "run", "
|
|
139
|
+
operation: strEnum("list", "create", "update", "run", "recover", "cancel", "steer", "follow_up", "resume", "contact", "reply", "models"),
|
|
109
140
|
task_id: Type.Optional(Type.String()),
|
|
110
141
|
run_id: Type.Optional(Type.String()),
|
|
111
142
|
group_id: Type.Optional(Type.String()),
|
|
112
143
|
message_id: Type.Optional(Type.String()),
|
|
113
144
|
message: Type.Optional(Type.String()),
|
|
114
|
-
reason: Type.Optional(
|
|
145
|
+
reason: Type.Optional(Type.String({ description: "contact: progress | decision. recover: why the delivered callback is not enough (required)." })),
|
|
115
146
|
session_mode: Type.Optional(strEnum("fresh")),
|
|
116
147
|
// The one-shot shorthand: a prompt is the whole delegation, and the
|
|
117
148
|
// fresh session in the caller's own directory is what it means.
|
|
@@ -130,7 +161,7 @@ export function taskToolSpec(execute) {
|
|
|
130
161
|
})),
|
|
131
162
|
join: Type.Optional(strEnum("all", "first")),
|
|
132
163
|
input: Type.Optional(Type.Unknown()),
|
|
133
|
-
callback: Type.Optional(strEnum("origin", "none")),
|
|
164
|
+
callback: Type.Optional(strEnum("origin", "none", "steer")),
|
|
134
165
|
callback_session_id: Type.Optional(Type.String()),
|
|
135
166
|
}),
|
|
136
167
|
execute,
|
|
@@ -156,6 +187,9 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
|
|
|
156
187
|
return definitions.update(requiredString(input.task_id, "task_id"), await expandDraft(definitions, input.task, callerSessionId));
|
|
157
188
|
}
|
|
158
189
|
if (input.operation === "run") {
|
|
190
|
+
// One reading of `callback` for both shapes below: a run and a fan-out
|
|
191
|
+
// choose the same way, and two readings are two things to keep in step.
|
|
192
|
+
const callbackMode = input.callback === "steer" ? "steer" : "followUp";
|
|
159
193
|
if (Array.isArray(input.tasks)) {
|
|
160
194
|
// Core-joined fan-out: members run detached, one aggregated callback.
|
|
161
195
|
if (input.task !== undefined || input.task_id !== undefined)
|
|
@@ -173,8 +207,9 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
|
|
|
173
207
|
? await resolveDraft(definitions, entry, active, callerSessionId)
|
|
174
208
|
: resolveStored(definitions, entry.task_id, active));
|
|
175
209
|
}
|
|
176
|
-
const
|
|
177
|
-
|
|
210
|
+
const groupCallbackSessionId = input.callback === "none" ? null : callerSessionId;
|
|
211
|
+
const { group, runs } = host.runGroup(resolved, input.join === "first" ? "first" : "all", callerSessionId, active?.id ?? null, groupCallbackSessionId, callbackMode);
|
|
212
|
+
return receipt(summarizeGroup(group, runs, messages), groupCallbackSessionId, callbackMode, callerSessionId);
|
|
178
213
|
}
|
|
179
214
|
const draft = input.task_id === undefined ? inlineDraft(input) : undefined;
|
|
180
215
|
const task = draft
|
|
@@ -196,23 +231,55 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
|
|
|
196
231
|
invokedBySessionId: callerSessionId,
|
|
197
232
|
sourceSessionId: callerSessionId,
|
|
198
233
|
callbackSessionId,
|
|
234
|
+
callbackMode,
|
|
199
235
|
background: true,
|
|
200
236
|
sessionMode,
|
|
201
237
|
});
|
|
202
|
-
return summarize(run, null);
|
|
238
|
+
return receipt(summarize(run, null), callbackSessionId, callbackMode, callerSessionId);
|
|
203
239
|
}
|
|
204
|
-
if (input.operation === "
|
|
240
|
+
if (input.operation === "recover") {
|
|
241
|
+
// History only, never status: a result is readable here once its callback
|
|
242
|
+
// has said its last word, so nothing a caller could learn by asking is
|
|
243
|
+
// something it would not have been told. The reason is the friction — a
|
|
244
|
+
// caller states why the callback did not suffice, and the operator sees it.
|
|
245
|
+
const reason = requiredString(input.reason, "reason");
|
|
246
|
+
// A finished run with an open decision sends no completion callback — the
|
|
247
|
+
// question is the notification, and the reply's continuation reports.
|
|
248
|
+
const decisionOpen = (run) => {
|
|
249
|
+
throw new Error(`run ${run.id} finished awaiting your decision ${messages.openDecisionId(run.id) ?? ""}; reply to it — the continuation's callback brings the result`);
|
|
250
|
+
};
|
|
251
|
+
// A member waits for its group's callback, but a race winner need not
|
|
252
|
+
// wait for losing members to finish cancelling before recovering its text.
|
|
253
|
+
const groupReady = (group) => {
|
|
254
|
+
if (!group.finishedAt || !settled(group))
|
|
255
|
+
notRecoverable(`group ${group.id}`, group);
|
|
256
|
+
};
|
|
205
257
|
if (typeof input.group_id === "string") {
|
|
206
258
|
const { group, members } = host.getGroup(input.group_id);
|
|
259
|
+
groupReady(group);
|
|
260
|
+
if (!members.every((run) => isTerminal(run.state))) {
|
|
261
|
+
throw new Error(`recover cannot inspect active members; the race has settled — recover its winning result with run_id ${group.winnerRunId ?? "from the callback"} and a reason`);
|
|
262
|
+
}
|
|
263
|
+
const asking = members.find((run) => messages.openDecisionId(run.id));
|
|
264
|
+
if (asking)
|
|
265
|
+
decisionOpen(asking);
|
|
266
|
+
log.info(`recover group ${group.id} by ${callerSessionId}: ${reason}`);
|
|
207
267
|
return summarizeGroup(group, members, messages);
|
|
208
268
|
}
|
|
209
|
-
// Run history by task: without it, checking what a task did (or whether a
|
|
210
|
-
// cascade landed) means leaving the tool for the database.
|
|
211
|
-
if (input.run_id === undefined && typeof input.task_id === "string") {
|
|
212
|
-
return host.listRuns(input.task_id, 10).map((run) => trimResult(summarize(run, messages.openDecisionId(run.id))));
|
|
213
|
-
}
|
|
214
269
|
const run = host.getRun(requiredString(input.run_id, "run_id"));
|
|
215
|
-
|
|
270
|
+
if (run.groupId) {
|
|
271
|
+
const { group } = host.getGroup(run.groupId);
|
|
272
|
+
groupReady(group);
|
|
273
|
+
if (!isTerminal(run.state))
|
|
274
|
+
throw new Error("the group has reported its outcome; recover reads finished results only and cannot wait for this member");
|
|
275
|
+
}
|
|
276
|
+
else if (!isTerminal(run.state) || !settled(run)) {
|
|
277
|
+
notRecoverable(`run ${run.id}`, run);
|
|
278
|
+
}
|
|
279
|
+
if (messages.openDecisionId(run.id))
|
|
280
|
+
decisionOpen(run);
|
|
281
|
+
log.info(`recover run ${run.id} by ${callerSessionId}: ${reason}`);
|
|
282
|
+
return summarize(run, null);
|
|
216
283
|
}
|
|
217
284
|
if (input.operation === "cancel") {
|
|
218
285
|
if (typeof input.group_id === "string") {
|
|
@@ -235,17 +302,22 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
|
|
|
235
302
|
if (input.operation === "resume") {
|
|
236
303
|
const prior = host.getRun(requiredString(input.run_id, "run_id"));
|
|
237
304
|
assertOwns(store, callerSessionId, active, prior);
|
|
305
|
+
const callbackSessionId = input.callback === "none" ? null : callerSessionId;
|
|
238
306
|
const run = host.resume(prior.id, requiredString(input.message, "message"), {
|
|
239
307
|
invokedBySessionId: callerSessionId,
|
|
240
|
-
callbackSessionId
|
|
308
|
+
callbackSessionId,
|
|
241
309
|
background: true,
|
|
242
310
|
});
|
|
243
|
-
return summarize(run, null);
|
|
311
|
+
return receipt(summarize(run, null), callbackSessionId, "followUp", callerSessionId);
|
|
244
312
|
}
|
|
245
313
|
if (input.operation === "contact") {
|
|
246
314
|
if (!active)
|
|
247
315
|
throw new Error("contact is only available inside an active Agent run");
|
|
248
|
-
|
|
316
|
+
// The schema no longer narrows `reason` (recover shares the field), so the
|
|
317
|
+
// two names contact accepts are checked here.
|
|
318
|
+
const reason = input.reason === undefined ? "progress" : input.reason;
|
|
319
|
+
if (reason !== "progress" && reason !== "decision")
|
|
320
|
+
throw new Error(`contact reason must be progress or decision, got ${String(reason)}`);
|
|
249
321
|
return messages.contact(active, callerSessionId, reason, requiredString(input.message, "message"));
|
|
250
322
|
}
|
|
251
323
|
if (input.operation === "reply") {
|
package/dist/tools.js
CHANGED
|
@@ -186,10 +186,10 @@ managedName = "reject") {
|
|
|
186
186
|
return tools;
|
|
187
187
|
}
|
|
188
188
|
/** `~/.pier/tools/…` — install target, generated ubix config, ubix state. */
|
|
189
|
-
|
|
189
|
+
const toolsDir = (...parts) => pierPath("tools", ...parts);
|
|
190
190
|
/** The one directory that goes on PATH: ubix installs into it, and everything
|
|
191
191
|
* Pier spawns inherits it. */
|
|
192
|
-
|
|
192
|
+
const toolsBin = () => toolsDir("bin");
|
|
193
193
|
/**
|
|
194
194
|
* First on PATH, once, at boot. First rather than last on purpose: a tool
|
|
195
195
|
* switched on in the Console is Pier's copy at Pier's version, whatever the
|
package/dist/update.js
CHANGED
|
@@ -129,7 +129,7 @@ export function startAutoUpdate(check, auto, pollMs = AUTO_POLL_MS) {
|
|
|
129
129
|
timer.unref(); // a pending check must never be what keeps the process alive
|
|
130
130
|
return () => clearInterval(timer);
|
|
131
131
|
}
|
|
132
|
-
|
|
132
|
+
async function fetchLatestVersion() {
|
|
133
133
|
// Plain JSON: the abbreviated-packument content type npm uses for a whole
|
|
134
134
|
// package is a 406 on this endpoint, which answers one version already.
|
|
135
135
|
const res = await fetch(ENDPOINT, {
|
package/dist/web/instance.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// layer-1 secrets control, the browser's error reports. Nothing here touches
|
|
3
3
|
// a session; server.ts stays the session/event surface.
|
|
4
4
|
import { logger } from "../log.js";
|
|
5
|
-
import { normalizeModelMenu, normalizePublicUrl, } from "../settings.js";
|
|
5
|
+
import { normalizeModelMenu, normalizeModelRef, normalizePublicUrl, } from "../settings.js";
|
|
6
6
|
/** A rule that failed inside the transaction — its own class so the route can
|
|
7
7
|
* tell it from a real fault. 400 when the request named something this Pier
|
|
8
8
|
* does not have, 409 when it collided with the state as it stands. */
|
|
@@ -131,11 +131,11 @@ export function registerInstanceRoutes(app, deps) {
|
|
|
131
131
|
app.put("/api/settings", async (c) => {
|
|
132
132
|
const body = await c.req.json().catch(() => null);
|
|
133
133
|
const fields = body
|
|
134
|
-
? [body.publicUrl, body.modelMenu, body.autoUpdate, body.customTools, body.extension, body.tool]
|
|
134
|
+
? [body.publicUrl, body.modelMenu, body.titleModel, body.autoUpdate, body.customTools, body.extension, body.tool]
|
|
135
135
|
: [];
|
|
136
136
|
if (!fields.some((v) => v !== undefined)) {
|
|
137
137
|
return c.json({
|
|
138
|
-
error: "publicUrl, modelMenu, autoUpdate, customTools, extension or tool required",
|
|
138
|
+
error: "publicUrl, modelMenu, titleModel, autoUpdate, customTools, extension or tool required",
|
|
139
139
|
}, 400);
|
|
140
140
|
}
|
|
141
141
|
// Everything is validated before anything is written, and everything is
|
|
@@ -157,6 +157,13 @@ export function registerInstanceRoutes(app, deps) {
|
|
|
157
157
|
return refuse("modelMenu must be [{provider, id, note?}] (≤32 entries)");
|
|
158
158
|
writes.push(() => settings.setModelMenu(menu));
|
|
159
159
|
}
|
|
160
|
+
if (body?.titleModel !== undefined) {
|
|
161
|
+
// null is the off switch; anything else has to be a model.
|
|
162
|
+
const ref = body.titleModel === null ? null : normalizeModelRef(body.titleModel);
|
|
163
|
+
if (ref === null && body.titleModel !== null)
|
|
164
|
+
return refuse("titleModel must be {provider, id} or null");
|
|
165
|
+
writes.push(() => settings.setTitleModel(ref));
|
|
166
|
+
}
|
|
160
167
|
if (body?.autoUpdate !== undefined) {
|
|
161
168
|
const { autoUpdate } = body;
|
|
162
169
|
if (typeof autoUpdate !== "boolean")
|