@timqi/pier 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -7
- package/dist/channels/slack-tool.js +28 -3
- package/dist/core/router.js +18 -4
- package/dist/main.js +98 -4
- package/dist/service.js +50 -0
- package/dist/settings.js +9 -1
- package/dist/tasks/service.js +13 -0
- package/dist/update.js +59 -7
- package/dist/web/auth.js +14 -4
- package/dist/web/explorer.js +12 -11
- package/dist/web/files.js +2 -1
- package/dist/web/instance.js +73 -7
- package/dist/web/providers.js +16 -4
- package/dist/web/public/assets/index-cCIuQnDr.css +2 -0
- package/dist/web/public/assets/index-fASxMPr6.js +90 -0
- package/dist/web/public/index.html +10 -7
- package/dist/web/server.js +69 -6
- package/docs/deploy.md +23 -1
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +16 -7
- package/skills/pier-slack/SKILL.md +17 -1
- package/dist/web/public/assets/index-BK64pHmP.js +0 -90
- package/dist/web/public/assets/index-De4GlOq4.css +0 -2
package/README.md
CHANGED
|
@@ -139,13 +139,24 @@ npm test # vitest
|
|
|
139
139
|
|
|
140
140
|
## Releases
|
|
141
141
|
|
|
142
|
-
Pier
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
142
|
+
Pier asks `registry.npmjs.org` at boot and every 30 minutes, and the version
|
|
143
|
+
beside the title turns into `v0.0.1 → 0.0.2` when a release is out. Clicking it
|
|
144
|
+
opens the panel: the source link, **Update now**, and **Update automatically**.
|
|
145
|
+
|
|
146
|
+
Nothing here installs anything itself — the work is handed to the second
|
|
147
|
+
systemd unit written at install time, because an npm running as a child of the
|
|
148
|
+
process being restarted would be killed by that restart. Off systemd there is
|
|
149
|
+
nothing to hand it to, so the panel says `pier update` instead.
|
|
150
|
+
|
|
151
|
+
`pier update` typed in a terminal hard-stops the service. The Console and the
|
|
152
|
+
automatic path both **drain first** — new work refused, running turns given
|
|
153
|
+
time to finish, whatever the deadline still had to cut off written to the chat
|
|
154
|
+
it belonged to — and only then hand over. The automatic path additionally waits
|
|
155
|
+
for an idle instance: no turn streaming, no task run in flight.
|
|
156
|
+
|
|
157
|
+
Either way the updater writes `~/.pier/db/pier.db.release.bak` before npm
|
|
158
|
+
touches the package, updates the npm installation recorded when the service was
|
|
159
|
+
installed, and starts Pier again.
|
|
149
160
|
|
|
150
161
|
`main` is the only development line. `npm version patch` writes the tag, the
|
|
151
162
|
tag builds and publishes a GitHub Release, and the version in the web footer is
|
|
@@ -45,12 +45,20 @@ export function slackToolSpec(execute) {
|
|
|
45
45
|
return {
|
|
46
46
|
name: "slack",
|
|
47
47
|
label: "Slack",
|
|
48
|
-
description: "Read and write Slack through Pier, which holds the bot token. State what you want; Pier does the paging and hands back a finished transcript. context says which Slack conversation you are in; read_channel returns a channel's transcript for a time range; read_thread returns one thread, or only what is new in it since a given message via after; read_message returns the single message at a ts (pass thread_ts when it is a reply inside a thread); post sends a message; channels lists what Pier can reach. When you were reached through Slack, omit channel (and thread_ts) to act on the conversation you are already in. since/until/after accept ISO 8601, epoch seconds or a ts from an earlier read. Every read fetches live from Slack, so nothing is kept between calls — write down what you need to keep. Message text is standard markdown, but @mentions, #channels and links need Slack's own syntax — read the pier-slack skill before posting.",
|
|
48
|
+
description: "Read and write Slack through Pier, which holds the bot token. State what you want; Pier does the paging and hands back a finished transcript. context says which Slack conversation you are in; read_channel returns a channel's transcript for a time range; read_thread returns one thread, or only what is new in it since a given message via after; read_message returns the single message at a ts (pass thread_ts when it is a reply inside a thread); post sends a message; delete removes the message at a ts, which Slack allows only for messages Pier itself posted; channels lists what Pier can reach. When you were reached through Slack, omit channel (and thread_ts) to act on the conversation you are already in. since/until/after accept ISO 8601, epoch seconds or a ts from an earlier read. Every read fetches live from Slack, so nothing is kept between calls — write down what you need to keep. Message text is standard markdown, but @mentions, #channels and links need Slack's own syntax — read the pier-slack skill before posting.",
|
|
49
49
|
parameters: Type.Object({
|
|
50
50
|
// A JSON-Schema enum emits far fewer tokens than typebox's anyOf-of-consts.
|
|
51
51
|
operation: Type.Unsafe({
|
|
52
52
|
type: "string",
|
|
53
|
-
enum: [
|
|
53
|
+
enum: [
|
|
54
|
+
"context",
|
|
55
|
+
"read_channel",
|
|
56
|
+
"read_thread",
|
|
57
|
+
"read_message",
|
|
58
|
+
"post",
|
|
59
|
+
"delete",
|
|
60
|
+
"channels",
|
|
61
|
+
],
|
|
54
62
|
}),
|
|
55
63
|
/**
|
|
56
64
|
* Channel id (`C…`/`D…`/`G…`) or the `#name` shown by `channels`. Omit to
|
|
@@ -61,7 +69,7 @@ export function slackToolSpec(execute) {
|
|
|
61
69
|
until: Type.Optional(Type.String()),
|
|
62
70
|
/** Strictly newer than this — "what changed since I last looked". */
|
|
63
71
|
after: Type.Optional(Type.String()),
|
|
64
|
-
/** The one message `read_message` is about. */
|
|
72
|
+
/** The one message `read_message` or `delete` is about. */
|
|
65
73
|
ts: Type.Optional(Type.String()),
|
|
66
74
|
limit: Type.Optional(Type.Number()),
|
|
67
75
|
thread_ts: Type.Optional(Type.String()),
|
|
@@ -174,6 +182,21 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
|
|
|
174
182
|
threadTs: threadTs ?? sent.ts,
|
|
175
183
|
};
|
|
176
184
|
}
|
|
185
|
+
if (input.operation === "delete") {
|
|
186
|
+
// Never defaulted from `here`: the thread's ts is the parent message, and
|
|
187
|
+
// "delete" with an implied target is the one mistake with no undo.
|
|
188
|
+
const ts = required(input.ts, "ts");
|
|
189
|
+
try {
|
|
190
|
+
await client.deleteMessage(channel, ts);
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
throw new Error(explain(err));
|
|
194
|
+
}
|
|
195
|
+
// A removal leaves nothing behind to read, so the log is the only record
|
|
196
|
+
// that it happened at all.
|
|
197
|
+
deps.log(`slack tool deleted ${ts} in ${channel}`);
|
|
198
|
+
return { channel, ts, deleted: true };
|
|
199
|
+
}
|
|
177
200
|
throw new Error(`unknown slack operation: ${String(input.operation)}`);
|
|
178
201
|
}
|
|
179
202
|
/** Accept a `#name` or a bare name as well as an id — models prefer names. */
|
|
@@ -285,6 +308,8 @@ function explain(err) {
|
|
|
285
308
|
missing_scope: "Pier's Slack app lacks the scope for this read; the operator must reinstall it",
|
|
286
309
|
ratelimited: "Slack rate-limited this read; wait a minute or ask for a narrower range",
|
|
287
310
|
thread_not_found: "no thread with that ts in this channel",
|
|
311
|
+
cant_delete_message: "Slack only lets Pier delete what its own bot posted; a person's message has to be deleted by them",
|
|
312
|
+
message_not_found: "no message with that ts in this channel — a ts only means anything in the conversation it came from",
|
|
288
313
|
}[code] ?? String(err);
|
|
289
314
|
}
|
|
290
315
|
/** Strictly newer, so `after: <last ts I saw>` never repeats that message. */
|
package/dist/core/router.js
CHANGED
|
@@ -29,8 +29,9 @@ export class Router {
|
|
|
29
29
|
channels = new Map();
|
|
30
30
|
/** Who each session last heard from, so a header costs tokens only on news. */
|
|
31
31
|
senders = new SenderPrefix();
|
|
32
|
-
/** Set
|
|
33
|
-
* exits when the drain ends
|
|
32
|
+
/** Set by a graceful restart (src/drain.ts). Usually never unset, because
|
|
33
|
+
* the process exits when the drain ends — `endDrain` exists for the one
|
|
34
|
+
* caller that drains *speculatively* and may not get to exit. */
|
|
34
35
|
draining = false;
|
|
35
36
|
constructor(hub,
|
|
36
37
|
/** Create or resume the session owning a conversation (wired in main.ts). */
|
|
@@ -91,13 +92,19 @@ export class Router {
|
|
|
91
92
|
*
|
|
92
93
|
* Skipped for anything that would notice: a streaming turn, and a session
|
|
93
94
|
* someone is still watching over SSE.
|
|
95
|
+
*
|
|
96
|
+
* `includeWatched` is the one caller that may take a watched session too:
|
|
97
|
+
* configuration a session reads only when it opens has just changed, and the
|
|
98
|
+
* session most likely to need it is the one open in the tab that changed it.
|
|
99
|
+
* A turn in flight is still never touched — the exemption that stands is the
|
|
100
|
+
* one about interrupting work, not the one about being looked at.
|
|
94
101
|
*/
|
|
95
|
-
async evictIdle(ttlMs = IDLE_TTL_MS, now = Date.now()) {
|
|
102
|
+
async evictIdle(ttlMs = IDLE_TTL_MS, now = Date.now(), { includeWatched = false } = {}) {
|
|
96
103
|
let evicted = 0;
|
|
97
104
|
for (const [id, attached] of [...this.bySession]) {
|
|
98
105
|
if (attached.session.state === "streaming")
|
|
99
106
|
continue;
|
|
100
|
-
if (this.hub.hasSubscribers(id))
|
|
107
|
+
if (!includeWatched && this.hub.hasSubscribers(id))
|
|
101
108
|
continue;
|
|
102
109
|
if (now - attached.activeAt < ttlMs)
|
|
103
110
|
continue;
|
|
@@ -237,6 +244,13 @@ export class Router {
|
|
|
237
244
|
beginDrain() {
|
|
238
245
|
this.draining = true;
|
|
239
246
|
}
|
|
247
|
+
/** Take work again. The auto-updater closes the gate *before* handing over,
|
|
248
|
+
* so a turn cannot slip in behind the idle check — and when the handover
|
|
249
|
+
* never happens, a Pier left refusing every message forever would be a far
|
|
250
|
+
* worse outcome than the race it was avoiding. */
|
|
251
|
+
endDrain() {
|
|
252
|
+
this.draining = false;
|
|
253
|
+
}
|
|
240
254
|
/** For surfaces that mutate state before dispatching (the web's edit and
|
|
241
255
|
* queue-deliver routes): ask first, so a refused dispatch cannot cost a
|
|
242
256
|
* rewound transcript or a cleared queue. */
|
package/dist/main.js
CHANGED
|
@@ -28,8 +28,9 @@ import { TaskStore } from "./tasks/store.js";
|
|
|
28
28
|
import { taskToolSpec } from "./tasks/tool.js";
|
|
29
29
|
import { PIER_HOME, pierPath } from "./paths.js";
|
|
30
30
|
import { Secrets } from "./secrets.js";
|
|
31
|
+
import { startUpdate, unitPath, updaterProblem } from "./service.js";
|
|
31
32
|
import { SettingsStore } from "./settings.js";
|
|
32
|
-
import { UpdateCheck } from "./update.js";
|
|
33
|
+
import { startAutoUpdate, UpdateCheck } from "./update.js";
|
|
33
34
|
import { AuthStore, registerAuthRoutes, requireAuth } from "./web/auth.js";
|
|
34
35
|
import { SessionStateStore } from "./web/session-state.js";
|
|
35
36
|
import { createServer } from "./web/server.js";
|
|
@@ -135,6 +136,100 @@ const startChannels = async () => {
|
|
|
135
136
|
.catch((err) => log.error("restart-note delivery failed", err));
|
|
136
137
|
};
|
|
137
138
|
void secrets.unlock().then(startChannels, (err) => log.error("secrets locked — channels not started; unlock from Console → Settings → Security, or repair master.key", err));
|
|
139
|
+
// Replacing Pier is systemd's job, not this process's: the oneshot unit stops
|
|
140
|
+
// the service, snapshots the database, installs and starts it again. Without
|
|
141
|
+
// that unit there is nothing to hand the work to, and the Console says so
|
|
142
|
+
// instead of offering a button that cannot work.
|
|
143
|
+
const updates = new UpdateCheck();
|
|
144
|
+
// Asked once at boot, not lazily on the first page load: a restart is exactly
|
|
145
|
+
// when "am I current?" is worth knowing, and it puts the answer in the journal
|
|
146
|
+
// of a Pier nobody has a browser open on.
|
|
147
|
+
void updates.refresh();
|
|
148
|
+
/**
|
|
149
|
+
* Hand over, but not onto a running turn. The updater's first act is
|
|
150
|
+
* `systemctl stop`, i.e. a SIGTERM, which is the *fast* teardown — so anything
|
|
151
|
+
* that started since the idle check would be killed with no note anywhere. The
|
|
152
|
+
* gate closes first and the drain waits, exactly as `pier restart` does,
|
|
153
|
+
* ledger included; only then is the install handed over. A handover that never
|
|
154
|
+
* starts reopens the gate, because a Pier that silently refuses every message
|
|
155
|
+
* forever is worse than the race it was avoiding.
|
|
156
|
+
*/
|
|
157
|
+
// Shared restart state. The updater's handover, the SIGUSR2 drain (below) and
|
|
158
|
+
// the final teardown must see each other: without this, two paths drain the
|
|
159
|
+
// same Pier at once, and a failure on one reopens the gate the other still
|
|
160
|
+
// needs shut.
|
|
161
|
+
let handingOver = false;
|
|
162
|
+
let draining = false;
|
|
163
|
+
let shuttingDown = false;
|
|
164
|
+
const takeWorkAgain = (why) => {
|
|
165
|
+
handingOver = false;
|
|
166
|
+
log.error(`${why} — taking work again`);
|
|
167
|
+
// Not ours to reopen: a SIGUSR2 restart or the teardown owns the gate now,
|
|
168
|
+
// and reopening it would hand new work to a process that is exiting.
|
|
169
|
+
if (draining || shuttingDown)
|
|
170
|
+
return;
|
|
171
|
+
router.endDrain();
|
|
172
|
+
tasks.unpause();
|
|
173
|
+
// The drain may have deadline-aborted turns into the ledger. Without the
|
|
174
|
+
// restart that was supposed to follow, that debt would wait for one days
|
|
175
|
+
// away (§5b) — so the chats are told now, by the process that cut them off.
|
|
176
|
+
void deliverLedger(restartLedger, (entry) => channels.notify(entry.channelId, entry.conversationId, entry.note))
|
|
177
|
+
.catch((err) => log.error("restart-note delivery failed", err));
|
|
178
|
+
};
|
|
179
|
+
/** How long the handover has to actually stop us. `systemctl start --no-block`
|
|
180
|
+
* returns when the job is *queued*, so "started" is not proof of anything;
|
|
181
|
+
* the real outcome is a SIGTERM a second or two later. */
|
|
182
|
+
const HANDOVER_GRACE_MS = 60_000;
|
|
183
|
+
const handOverToUpdater = async () => {
|
|
184
|
+
// One handover at a time, and never on top of a restart: the Console button,
|
|
185
|
+
// the auto-update tick and SIGUSR2 would otherwise drain the same Pier
|
|
186
|
+
// twice, each believing the gate is its own to reopen on failure.
|
|
187
|
+
if (handingOver || draining || shuttingDown)
|
|
188
|
+
return "busy";
|
|
189
|
+
handingOver = true;
|
|
190
|
+
await drainForRestart({ router, tasks, ledger: restartLedger });
|
|
191
|
+
const started = startUpdate({ say: (message) => log.info(message) });
|
|
192
|
+
if (started !== "started") {
|
|
193
|
+
takeWorkAgain(`update not started (${started})`);
|
|
194
|
+
return started;
|
|
195
|
+
}
|
|
196
|
+
// The gate is closed and nothing in this process will open it again, so a
|
|
197
|
+
// handover that queues and then goes nowhere — npm failed, the unit was
|
|
198
|
+
// masked, the job sat behind another — would leave Pier alive and refusing
|
|
199
|
+
// every message with no way back. Unref'd: this must not be what keeps the
|
|
200
|
+
// process up while systemd is trying to stop it.
|
|
201
|
+
setTimeout(() => {
|
|
202
|
+
takeWorkAgain(`still running ${String(HANDOVER_GRACE_MS / 1000)}s after handing over — pier-update.service never stopped Pier` +
|
|
203
|
+
` (check: journalctl --user -u pier-update.service -e)`);
|
|
204
|
+
}, HANDOVER_GRACE_MS).unref();
|
|
205
|
+
return started;
|
|
206
|
+
};
|
|
207
|
+
const updater = process.platform === "linux" && existsSync(unitPath())
|
|
208
|
+
? { apply: handOverToUpdater, problem: () => updaterProblem() }
|
|
209
|
+
: null;
|
|
210
|
+
// Unattended only when the operator asked for it *and* nothing is running.
|
|
211
|
+
if (updater) {
|
|
212
|
+
const problem = updaterProblem();
|
|
213
|
+
// Loudly, at boot: this is the one moment the operator is looking, and the
|
|
214
|
+
// alternative is a restart that fails months from now.
|
|
215
|
+
if (problem)
|
|
216
|
+
log.warn(`the updater cannot run: ${problem}`);
|
|
217
|
+
startAutoUpdate(updates, {
|
|
218
|
+
enabled: () => settings.get().autoUpdate,
|
|
219
|
+
idle: () => router.busy().length === 0 && tasks.activeRunCount() === 0,
|
|
220
|
+
apply: async () => {
|
|
221
|
+
// Re-checked here, not only at boot: a version manager can remove the
|
|
222
|
+
// recorded Node months into an uptime, and draining for a handover that
|
|
223
|
+
// cannot happen would take the whole instance down with it.
|
|
224
|
+
const now = updaterProblem();
|
|
225
|
+
if (now) {
|
|
226
|
+
log.error(`auto-update skipped: ${now}`);
|
|
227
|
+
return "not-installed";
|
|
228
|
+
}
|
|
229
|
+
return handOverToUpdater();
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
}
|
|
138
233
|
// Composition happens here so web/ and tasks/ never import each other.
|
|
139
234
|
const app = new Hono();
|
|
140
235
|
// A route that threw would otherwise answer 500 and leave no trace anywhere:
|
|
@@ -162,7 +257,8 @@ app.route("/", createServer({
|
|
|
162
257
|
providers: factory,
|
|
163
258
|
settings,
|
|
164
259
|
secrets,
|
|
165
|
-
updates
|
|
260
|
+
updates,
|
|
261
|
+
updater,
|
|
166
262
|
// Unlocked from the Console: start the channels boot held back.
|
|
167
263
|
onUnlocked: () => void startChannels(),
|
|
168
264
|
backgroundRuns: (id) => tasks.backgroundRuns(id),
|
|
@@ -185,7 +281,6 @@ process.on("uncaughtException", (err) => {
|
|
|
185
281
|
process.on("unhandledRejection", (reason) => {
|
|
186
282
|
log.error("unhandled rejection", reason);
|
|
187
283
|
});
|
|
188
|
-
let shuttingDown = false;
|
|
189
284
|
const shutdown = (stopTasks = true) => {
|
|
190
285
|
// Once: SIGTERM can land while a drain is finishing, and two teardowns
|
|
191
286
|
// racing each other close the same sockets twice.
|
|
@@ -221,7 +316,6 @@ for (const signal of ["SIGTERM", "SIGINT"]) {
|
|
|
221
316
|
// next process up. SIGTERM above stays the fast path systemd expects. `on`,
|
|
222
317
|
// not `once`: a second SIGUSR2 with no handler would fall back to Node's
|
|
223
318
|
// default and kill the drain it meant to hurry.
|
|
224
|
-
let draining = false;
|
|
225
319
|
process.on("SIGUSR2", () => {
|
|
226
320
|
if (draining) {
|
|
227
321
|
log.info("SIGUSR2 received again — already draining");
|
package/dist/service.js
CHANGED
|
@@ -185,6 +185,56 @@ export function install(options) {
|
|
|
185
185
|
say(`started. The first run prints a password once: journalctl --user -u pier -e`);
|
|
186
186
|
return true;
|
|
187
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Why the installed updater could not do its job, or `null` when nothing is
|
|
190
|
+
* wrong. Checked while Pier is still alive, because the alternative is finding
|
|
191
|
+
* out at the next restart, from a service that no longer starts.
|
|
192
|
+
*
|
|
193
|
+
* The absolute node and npm paths in the unit are deliberate — systemd's PATH
|
|
194
|
+
* has neither — but they pin the unit to one directory of one version manager.
|
|
195
|
+
* `fnm install 26 && fnm uninstall 24` leaves ExecStart naming a Node that is
|
|
196
|
+
* gone; the running process survives (Linux keeps a deleted binary mapped),
|
|
197
|
+
* so nothing would notice until the update, or the next boot, failed.
|
|
198
|
+
*/
|
|
199
|
+
export function updaterProblem(home = homedir()) {
|
|
200
|
+
if (!existsSync(unitPath(home)))
|
|
201
|
+
return null; // not a service install; nothing to check
|
|
202
|
+
const path = updateUnitPath(home);
|
|
203
|
+
let unit;
|
|
204
|
+
if (existsSync(path)) {
|
|
205
|
+
try {
|
|
206
|
+
unit = readFileSync(path, "utf8");
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
return `${path} cannot be read: ${String(err)}`;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
// A 0.0.1 install: startUpdate generates the updater from the main unit,
|
|
214
|
+
// so a missing file is only a problem when that bridge cannot either —
|
|
215
|
+
// and the generated text gets the same executable check below.
|
|
216
|
+
try {
|
|
217
|
+
unit = renderUpdateUnit(legacyOptions(home));
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return `${UPDATE_UNIT_NAME} is missing — run: pier service install --force`;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// The one line that names both executables, quoted and escaped by quote().
|
|
224
|
+
// Unparseable means hand-edited, which is not this function's business to
|
|
225
|
+
// judge; the escaping is undone before existsSync sees a path (a `%` or `$`
|
|
226
|
+
// in it would otherwise read as gone on a working updater).
|
|
227
|
+
const install = unit.match(/^ExecStart="((?:\\.|[^"\r\n])+)" "((?:\\.|[^"\r\n])+)" install -g/m);
|
|
228
|
+
if (!install)
|
|
229
|
+
return null;
|
|
230
|
+
const unescape = (word) => word.replaceAll("$$", "$").replaceAll("%%", "%").replace(/\\(.)/g, "$1");
|
|
231
|
+
for (const [what, bin] of [["node", unescape(install[1])], ["npm", unescape(install[2])]]) {
|
|
232
|
+
if (!existsSync(bin)) {
|
|
233
|
+
return `the ${what} the updater would use is gone (${bin}) — a version manager removed it; run: pier service install --force`;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
188
238
|
function runningPierHome(home) {
|
|
189
239
|
const pid = Number(execFileSync("systemctl", ["--user", "show", UNIT_NAME, "--property=MainPID", "--value"], { encoding: "utf8" }).trim());
|
|
190
240
|
if (Number.isInteger(pid) && pid >= 1) {
|
package/dist/settings.js
CHANGED
|
@@ -72,7 +72,11 @@ export class SettingsStore {
|
|
|
72
72
|
this.#db = db;
|
|
73
73
|
}
|
|
74
74
|
get() {
|
|
75
|
-
return {
|
|
75
|
+
return {
|
|
76
|
+
publicUrl: this.#value("publicUrl") ?? "",
|
|
77
|
+
modelMenu: this.#menu(),
|
|
78
|
+
autoUpdate: this.#value("autoUpdate") === "1",
|
|
79
|
+
};
|
|
76
80
|
}
|
|
77
81
|
#menu() {
|
|
78
82
|
const raw = this.#value("modelMenu");
|
|
@@ -105,6 +109,10 @@ export class SettingsStore {
|
|
|
105
109
|
this.#set("modelMenu", JSON.stringify(menu));
|
|
106
110
|
return this.get();
|
|
107
111
|
}
|
|
112
|
+
setAutoUpdate(on) {
|
|
113
|
+
this.#set("autoUpdate", on ? "1" : "0");
|
|
114
|
+
return this.get();
|
|
115
|
+
}
|
|
108
116
|
#set(key, value) {
|
|
109
117
|
this.#db.prepare(`
|
|
110
118
|
INSERT INTO settings(key, value) VALUES (?, ?)
|
package/dist/tasks/service.js
CHANGED
|
@@ -85,6 +85,9 @@ export class TaskService {
|
|
|
85
85
|
this.definitions.resetNextRuns(now);
|
|
86
86
|
this.callbacks.recover(now);
|
|
87
87
|
this.groups.recover(now);
|
|
88
|
+
this.runTimer(tickMs);
|
|
89
|
+
}
|
|
90
|
+
runTimer(tickMs) {
|
|
88
91
|
this.timer = setInterval(() => {
|
|
89
92
|
// The scheduler's own loop: a throw here would stop nothing (the next
|
|
90
93
|
// tick still fires) and say nothing, so due tasks would just stop.
|
|
@@ -92,6 +95,16 @@ export class TaskService {
|
|
|
92
95
|
}, tickMs);
|
|
93
96
|
this.timer.unref();
|
|
94
97
|
}
|
|
98
|
+
/** Undo a `pause()` that was not followed by an exit — the auto-updater
|
|
99
|
+
* drains before handing over, and a handover that never started must not
|
|
100
|
+
* leave the scheduler switched off. Deliberately not `start()`: the boot
|
|
101
|
+
* recovery in there would write off runs this process is still running. */
|
|
102
|
+
unpause(tickMs = 1000) {
|
|
103
|
+
if (this.timer)
|
|
104
|
+
return;
|
|
105
|
+
this.paused = false;
|
|
106
|
+
this.runTimer(tickMs);
|
|
107
|
+
}
|
|
95
108
|
stop() {
|
|
96
109
|
this.pause();
|
|
97
110
|
this.execution.stop();
|
package/dist/update.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
|
-
//
|
|
1
|
+
// The newer Pier: whether one exists, and when this one may become it.
|
|
2
2
|
//
|
|
3
3
|
// The registry is asked, not the GitHub API: `registry.npmjs.org` is a CDN with
|
|
4
4
|
// no rate limit and no token, and it is the same place `npm i -g` would look,
|
|
5
5
|
// so what it reports is what an update would actually get.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// —
|
|
7
|
+
// Nothing here installs anything. Applying is handed to whatever supervises
|
|
8
|
+
// this process (service.ts's oneshot unit, injected as `apply`): a web server
|
|
9
|
+
// holding provider keys must not npm-install as its own child, and the two
|
|
10
|
+
// gates below — the operator switched it on, and nothing is running — are why
|
|
11
|
+
// a self-replacing timer is not simply a supply-chain surface (AGENTS.md 8).
|
|
11
12
|
import { createRequire } from "node:module";
|
|
12
13
|
import { logger } from "./log.js";
|
|
13
14
|
const log = logger("update");
|
|
14
15
|
const PACKAGE = "@timqi/pier";
|
|
15
16
|
const ENDPOINT = `https://registry.npmjs.org/${PACKAGE}/latest`;
|
|
16
|
-
/**
|
|
17
|
-
|
|
17
|
+
/** One conditional-GET-sized request against a CDN, so the cost of asking is
|
|
18
|
+
* not what sets this — how long a released fix may sit unnoticed is. */
|
|
19
|
+
const TTL_MS = 30 * 60_000;
|
|
18
20
|
const TIMEOUT_MS = 5_000;
|
|
19
21
|
export const currentVersion = () => createRequire(import.meta.url)("../package.json").version;
|
|
20
22
|
export const isValidVersion = (version) => /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(version);
|
|
@@ -59,6 +61,15 @@ export class UpdateCheck {
|
|
|
59
61
|
available: this.#latest !== null && isNewer(this.#latest, this.current),
|
|
60
62
|
};
|
|
61
63
|
}
|
|
64
|
+
/** The answer, waiting for the very first check instead of reporting "no
|
|
65
|
+
* idea". A browser asks once per page load, so a process that had never
|
|
66
|
+
* checked told every one of them `latest: null` — which is exactly how a
|
|
67
|
+
* published release looked undetected. Later loads are served from cache. */
|
|
68
|
+
async statusNow() {
|
|
69
|
+
if (this.#checkedAt === 0)
|
|
70
|
+
await this.refresh();
|
|
71
|
+
return this.status();
|
|
72
|
+
}
|
|
62
73
|
/** Ask now. Concurrent callers share the one request. */
|
|
63
74
|
refresh() {
|
|
64
75
|
this.#inFlight ??= this.fetchLatest()
|
|
@@ -77,6 +88,47 @@ export class UpdateCheck {
|
|
|
77
88
|
return this.#inFlight;
|
|
78
89
|
}
|
|
79
90
|
}
|
|
91
|
+
/** Often enough to catch an idle window on a busy box, and cheap: the registry
|
|
92
|
+
* itself is still only asked once per TTL (`status()` owns that). */
|
|
93
|
+
const AUTO_POLL_MS = 15 * 60_000;
|
|
94
|
+
/** Watch for the moment all three conditions hold. Returns its own stop. */
|
|
95
|
+
export function startAutoUpdate(check, auto, pollMs = AUTO_POLL_MS) {
|
|
96
|
+
// A handover drains first, which can outlast a poll interval; a second
|
|
97
|
+
// attempt on top of it would drain an already-draining Pier.
|
|
98
|
+
let handingOver = false;
|
|
99
|
+
const tick = async () => {
|
|
100
|
+
if (handingOver || !auto.enabled())
|
|
101
|
+
return;
|
|
102
|
+
// Refreshes in the background when stale; today's answer is good enough,
|
|
103
|
+
// because the next tick is a quarter of an hour away either way.
|
|
104
|
+
const { latest, available } = check.status();
|
|
105
|
+
if (!available || !auto.idle())
|
|
106
|
+
return;
|
|
107
|
+
log.info(`auto-update: idle and ${latest ?? "a newer version"} is out — handing over to the updater`);
|
|
108
|
+
handingOver = true;
|
|
109
|
+
try {
|
|
110
|
+
const started = await auto.apply();
|
|
111
|
+
// Not silent (§5b): an update that never happens must not look like an
|
|
112
|
+
// update that was never wanted. `busy` is the one non-start that is
|
|
113
|
+
// fine — someone else is already restarting this Pier.
|
|
114
|
+
if (started === "busy")
|
|
115
|
+
log.info("auto-update: a handover or restart is already in progress");
|
|
116
|
+
else if (started !== "started")
|
|
117
|
+
log.error(`auto-update could not start: ${started}`);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
log.error("auto-update failed", err);
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
// Only reached when the handover did *not* take the process with it, so
|
|
124
|
+
// the next tick is allowed to try again.
|
|
125
|
+
handingOver = false;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const timer = setInterval(() => void tick(), pollMs);
|
|
129
|
+
timer.unref(); // a pending check must never be what keeps the process alive
|
|
130
|
+
return () => clearInterval(timer);
|
|
131
|
+
}
|
|
80
132
|
export async function fetchLatestVersion() {
|
|
81
133
|
// Plain JSON: the abbreviated-packument content type npm uses for a whole
|
|
82
134
|
// package is a 406 on this endpoint, which answers one version already.
|
package/dist/web/auth.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// header) because the workbench lives on SSE, and EventSource sends no headers.
|
|
18
18
|
import { createHash, createHmac, randomBytes, randomInt, scryptSync, timingSafeEqual, } from "node:crypto";
|
|
19
19
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
20
|
-
import { getCookie, setCookie } from "hono/cookie";
|
|
20
|
+
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
21
21
|
import { pierDb } from "../db.js";
|
|
22
22
|
import { logger } from "../log.js";
|
|
23
23
|
const log = logger("auth");
|
|
@@ -271,9 +271,19 @@ export function registerAuthRoutes(app, store) {
|
|
|
271
271
|
}
|
|
272
272
|
failures.delete(client);
|
|
273
273
|
store.setPassword(next);
|
|
274
|
-
// The rotation just killed this caller's
|
|
275
|
-
//
|
|
276
|
-
|
|
274
|
+
// The rotation just killed every cookie out there, this caller's included —
|
|
275
|
+
// a password is changed because the old one may be known, and "everyone
|
|
276
|
+
// signs in again" is the whole point. Clear the dead cookie; the client
|
|
277
|
+
// sends the person to the login form with the password they just chose.
|
|
278
|
+
deleteCookie(c, COOKIE, { path: "/" });
|
|
279
|
+
return c.json({ ok: true });
|
|
280
|
+
});
|
|
281
|
+
// Signs out this browser by clearing its cookie. The value itself stays
|
|
282
|
+
// verifiable until it expires — it is a signature, not a stored id — so the
|
|
283
|
+
// full revocation story remains the password change above. Behind the
|
|
284
|
+
// boundary like every write: only a signed-in browser has anything to end.
|
|
285
|
+
app.post("/logout", (c) => {
|
|
286
|
+
deleteCookie(c, COOKIE, { path: "/" });
|
|
277
287
|
return c.json({ ok: true });
|
|
278
288
|
});
|
|
279
289
|
}
|
package/dist/web/explorer.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Files view backend: read-only directory listing, file bytes and git
|
|
2
|
-
// ref/diff queries for the Console's Files view.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// ref/diff queries for the Console's Files view. `root` is any directory the
|
|
3
|
+
// process can read — sessions work in worktrees and siblings of their cwd, and
|
|
4
|
+
// an owner who is already past the Console password can reach those paths
|
|
5
|
+
// anyway. `path` is still confined to the `root` it was asked under, so a
|
|
6
|
+
// listing can never widen itself, and nothing here writes.
|
|
5
7
|
import { execFile } from "node:child_process";
|
|
6
8
|
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
7
9
|
import { basename, extname, isAbsolute, resolve, sep } from "node:path";
|
|
@@ -27,17 +29,16 @@ const MAX_DIFF_BYTES = 2 * 1024 * 1024;
|
|
|
27
29
|
const REF_RE = /^[^-\s][^\s]*$/;
|
|
28
30
|
/** git in `root`, output capped — a diff is display data, not an archive. */
|
|
29
31
|
const git = async (root, ...args) => (await run("git", ["-C", root, ...args], { maxBuffer: MAX_DIFF_BYTES })).stdout;
|
|
30
|
-
export function registerExplorerRoutes(app
|
|
31
|
-
/** The scope check every route shares: `root` must be
|
|
32
|
-
*
|
|
33
|
-
*
|
|
32
|
+
export function registerExplorerRoutes(app) {
|
|
33
|
+
/** The scope check every route shares: `root` must be an absolute directory,
|
|
34
|
+
* and `path` must resolve inside it (realpath both ends — neither `..` nor a
|
|
35
|
+
* symlink steps outside). Returns the real target. */
|
|
34
36
|
const resolveScoped = async (root, path = "") => {
|
|
35
37
|
if (!root || !isAbsolute(root))
|
|
36
|
-
throw new Error("root must be
|
|
37
|
-
const known = new Set([...(await factory.list()).map((s) => s.cwd), ...nascentCwds()]);
|
|
38
|
-
if (!known.has(root))
|
|
39
|
-
throw new Error("root must be a known project directory");
|
|
38
|
+
throw new Error("root must be an absolute directory");
|
|
40
39
|
const real = await realpath(root);
|
|
40
|
+
if (!(await stat(real)).isDirectory())
|
|
41
|
+
throw new Error("root must be an absolute directory");
|
|
41
42
|
const target = await realpath(resolve(real, path));
|
|
42
43
|
if (target !== real && !target.startsWith(real + sep))
|
|
43
44
|
throw new Error("path escapes root");
|
package/dist/web/files.js
CHANGED
|
@@ -39,7 +39,7 @@ export function guarded(app, method, path, status, fn) {
|
|
|
39
39
|
}
|
|
40
40
|
});
|
|
41
41
|
}
|
|
42
|
-
export function registerFileRoutes(app, { factory, config, nascentCwd }) {
|
|
42
|
+
export function registerFileRoutes(app, { factory, config, nascentCwd, onConfigWritten }) {
|
|
43
43
|
// Scope comes from the client as "global" or a project cwd; only cwds Pi
|
|
44
44
|
// already knows (the session list) are accepted — never an arbitrary path.
|
|
45
45
|
const parseScope = async (raw) => {
|
|
@@ -77,6 +77,7 @@ export function registerFileRoutes(app, { factory, config, nascentCwd }) {
|
|
|
77
77
|
}
|
|
78
78
|
const name = c.req.param("name");
|
|
79
79
|
await config.writeFile(scope, name, body.content, body.expected);
|
|
80
|
+
onConfigWritten?.();
|
|
80
81
|
return c.json({ ok: true, content: await config.readFile(scope, name) });
|
|
81
82
|
});
|
|
82
83
|
// Resource names may contain slashes — query params, not path params.
|