@timqi/pier 0.0.17 → 0.0.20
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 +123 -0
- package/dist/agent/config.js +95 -3
- package/dist/agent/events.js +26 -5
- package/dist/agent/listing.js +12 -13
- package/dist/agent/pi.js +75 -27
- package/dist/boards/boards.js +7 -6
- package/dist/channels/attach.js +18 -4
- package/dist/channels/config.js +7 -2
- package/dist/channels/dedup.js +9 -1
- package/dist/channels/lark.js +2 -6
- package/dist/channels/receipts.js +21 -6
- package/dist/channels/slack-tool.js +86 -25
- package/dist/channels/slack.js +2 -10
- package/dist/channels/telegram.js +1 -0
- package/dist/config-sync-fetch.js +97 -0
- package/dist/config-sync-task.js +59 -0
- package/dist/config-sync.js +179 -0
- package/dist/core/hub.js +23 -6
- package/dist/core/inbox.js +1 -1
- package/dist/core/reply.js +76 -2
- package/dist/core/router.js +162 -37
- package/dist/db.js +50 -0
- package/dist/main.js +32 -1
- package/dist/secrets.js +18 -6
- package/dist/tasks/agent.js +25 -23
- package/dist/tasks/callbacks.js +1 -1
- package/dist/tasks/definitions.js +28 -17
- package/dist/tasks/execution.js +11 -3
- package/dist/tasks/messages.js +58 -16
- package/dist/tasks/routes.js +58 -10
- package/dist/tasks/runs.js +0 -5
- package/dist/tasks/service.js +14 -4
- package/dist/tasks/store.js +87 -10
- package/dist/tasks/tool.js +8 -4
- package/dist/tools.js +32 -2
- package/dist/web/auth.js +17 -23
- package/dist/web/config-sync.js +91 -0
- package/dist/web/config.js +61 -0
- package/dist/web/explorer.js +20 -70
- package/dist/web/fs.js +159 -0
- package/dist/web/instance.js +11 -0
- package/dist/web/public/assets/activity-DYo7QS7k.js +5 -0
- package/dist/web/public/assets/activity-DYo7QS7k.js.br +0 -0
- package/dist/web/public/assets/activity-DYo7QS7k.js.gz +0 -0
- package/dist/web/public/assets/boards-CwohTbtv.js +1 -0
- package/dist/web/public/assets/boards-CwohTbtv.js.br +0 -0
- package/dist/web/public/assets/boards-CwohTbtv.js.gz +0 -0
- package/dist/web/public/assets/code-p-Tq1bMK.js +1 -0
- package/dist/web/public/assets/explorer-50no_DrD.js +5 -0
- package/dist/web/public/assets/explorer-50no_DrD.js.br +0 -0
- package/dist/web/public/assets/explorer-50no_DrD.js.gz +0 -0
- package/dist/web/public/assets/{ghostty-web-CV7F9_iW.js → ghostty-web-Bvhh3ZFk.js} +1 -1
- package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.br +0 -0
- package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.gz +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js +3 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
- package/dist/web/public/assets/{index-CtR-Ob4T.css → index-DuZ2vERd.css} +1 -1
- package/dist/web/public/assets/index-DuZ2vERd.css.br +0 -0
- package/dist/web/public/assets/index-DuZ2vERd.css.gz +0 -0
- package/dist/web/public/assets/index-WrsNstrb.js +79 -0
- package/dist/web/public/assets/index-WrsNstrb.js.br +0 -0
- package/dist/web/public/assets/index-WrsNstrb.js.gz +0 -0
- package/dist/web/public/assets/runs-BAXZF-6r.js +1 -0
- package/dist/web/public/assets/runs-BAXZF-6r.js.br +0 -0
- package/dist/web/public/assets/runs-BAXZF-6r.js.gz +0 -0
- package/dist/web/public/assets/settings-BmNXgRne.js +6 -0
- package/dist/web/public/assets/settings-BmNXgRne.js.br +0 -0
- package/dist/web/public/assets/settings-BmNXgRne.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CpgHxFLf.js +3 -0
- package/dist/web/public/assets/task-runs-CpgHxFLf.js.br +0 -0
- package/dist/web/public/assets/task-runs-CpgHxFLf.js.gz +0 -0
- package/dist/web/public/assets/tasks-DybtogPF.js +4 -0
- package/dist/web/public/assets/tasks-DybtogPF.js.br +0 -0
- package/dist/web/public/assets/tasks-DybtogPF.js.gz +0 -0
- package/dist/web/public/assets/terminal-m2_WYu5W.js +2 -0
- package/dist/web/public/assets/terminal-m2_WYu5W.js.br +0 -0
- package/dist/web/public/assets/terminal-m2_WYu5W.js.gz +0 -0
- package/dist/web/public/icon.svg.br +0 -0
- package/dist/web/public/icon.svg.gz +0 -0
- package/dist/web/public/index.html +13 -2
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest.br +0 -0
- package/dist/web/public/manifest.webmanifest.gz +0 -0
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/route.js +17 -0
- package/dist/web/server.js +152 -66
- package/dist/web/terminal.js +2 -7
- package/package.json +2 -2
- package/skills/pier-slack/SKILL.md +4 -16
- package/skills/pier-tasks/SKILL.md +14 -9
- package/dist/web/files.js +0 -183
- package/dist/web/public/assets/index-DAxKtidH.js +0 -93
package/dist/tasks/service.js
CHANGED
|
@@ -44,7 +44,7 @@ export class TaskService {
|
|
|
44
44
|
this.instance = instance;
|
|
45
45
|
const unreachable = (sessionId, what, why) => this.unreachable(sessionId, what, why);
|
|
46
46
|
this.messages = new TaskMessenger(store, router, hub, (runId, prompt, fromSessionId) => this.resume(runId, prompt, { invokedBySessionId: fromSessionId, callbackSessionId: fromSessionId, background: true }), unreachable);
|
|
47
|
-
this.definitions = new TaskDefinitions(store, factory, router, hub);
|
|
47
|
+
this.definitions = new TaskDefinitions(store, factory, router, hub, instance?.systemActions);
|
|
48
48
|
this.callbacks = new TaskCallbacks(store, router, (run) => this.changed(run), unreachable);
|
|
49
49
|
this.groups = new TaskGroups(store, router, {
|
|
50
50
|
getRun: (id) => this.getRun(id),
|
|
@@ -164,9 +164,6 @@ export class TaskService {
|
|
|
164
164
|
archive(id, by) {
|
|
165
165
|
return this.definitions.archive(id, by);
|
|
166
166
|
}
|
|
167
|
-
sessionExists(sessionId) {
|
|
168
|
-
return this.definitions.sessionExists(sessionId);
|
|
169
|
-
}
|
|
170
167
|
listRuns(taskId, limit = 50, offset = 0) {
|
|
171
168
|
this.get(taskId);
|
|
172
169
|
return this.store.listRuns(taskId, Math.min(Math.max(limit, 1), 200), Math.max(offset, 0));
|
|
@@ -177,6 +174,11 @@ export class TaskService {
|
|
|
177
174
|
throw new Error(`unknown task run: ${id}`);
|
|
178
175
|
return run;
|
|
179
176
|
}
|
|
177
|
+
getRunView(id) {
|
|
178
|
+
const run = this.getRun(id);
|
|
179
|
+
return { ...run, pendingDecisionId: this.openDecisionId(id),
|
|
180
|
+
groupCallbackState: run.groupId ? this.store.getGroup(run.groupId)?.callbackState ?? null : null };
|
|
181
|
+
}
|
|
180
182
|
listMessages(runId) {
|
|
181
183
|
this.getRun(runId);
|
|
182
184
|
return this.messages.list(runId);
|
|
@@ -184,6 +186,9 @@ export class TaskService {
|
|
|
184
186
|
openDecisionId(runId) {
|
|
185
187
|
return this.messages.openDecisionId(runId);
|
|
186
188
|
}
|
|
189
|
+
queryRuns(query = {}) {
|
|
190
|
+
return this.store.queryRuns(query);
|
|
191
|
+
}
|
|
187
192
|
recentRuns(limit = 100) {
|
|
188
193
|
return this.store.listRecentRuns(limit);
|
|
189
194
|
}
|
|
@@ -198,6 +203,11 @@ export class TaskService {
|
|
|
198
203
|
.reverse()
|
|
199
204
|
.map((run) => this.backgroundRun(run));
|
|
200
205
|
}
|
|
206
|
+
/** The same runs `backgroundRuns` reports as in flight, counted per session
|
|
207
|
+
* in one query — a list needs the number, not the runs. */
|
|
208
|
+
activeBackgroundRunCounts() {
|
|
209
|
+
return this.store.countActiveBackgroundRunsBySession();
|
|
210
|
+
}
|
|
201
211
|
run(taskId, input = null, source = "manual", parentRunId = null, provenance = {}) {
|
|
202
212
|
this.refusePaused(parentRunId);
|
|
203
213
|
const task = this.get(taskId);
|
package/dist/tasks/store.js
CHANGED
|
@@ -2,22 +2,25 @@
|
|
|
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 } from "../db.js";
|
|
5
|
+
import { pierDb, statements } from "../db.js";
|
|
6
6
|
const clamp = (limit, cap) => Math.min(Math.max(limit, 1), cap);
|
|
7
7
|
export class TaskStore {
|
|
8
8
|
db;
|
|
9
|
+
/** Every query below is a fixed string, so each is compiled once. */
|
|
10
|
+
sql;
|
|
9
11
|
constructor(db = pierDb()) {
|
|
10
12
|
this.db = db;
|
|
13
|
+
this.sql = statements(db);
|
|
11
14
|
}
|
|
12
15
|
// Every table is one JSON column plus query columns; these two are the only
|
|
13
16
|
// readers, and the one seam where a future schema change normalizes old rows
|
|
14
17
|
// (pre-v1 databases are refused outright in db.ts).
|
|
15
18
|
#one(sql, ...params) {
|
|
16
|
-
const row = this.
|
|
19
|
+
const row = this.sql(sql).get(...params);
|
|
17
20
|
return row ? JSON.parse(row.json) : undefined;
|
|
18
21
|
}
|
|
19
22
|
#many(sql, ...params) {
|
|
20
|
-
return this.
|
|
23
|
+
return this.sql(sql).all(...params)
|
|
21
24
|
.map((row) => JSON.parse(row.json));
|
|
22
25
|
}
|
|
23
26
|
listTasks() {
|
|
@@ -27,11 +30,17 @@ export class TaskStore {
|
|
|
27
30
|
return this.#one("SELECT json FROM tasks WHERE id = ?", id);
|
|
28
31
|
}
|
|
29
32
|
saveTask(task) {
|
|
30
|
-
this.
|
|
33
|
+
this.sql(`
|
|
31
34
|
INSERT INTO tasks(id, updated_at, json) VALUES (?, ?, ?)
|
|
32
35
|
ON CONFLICT(id) DO UPDATE SET updated_at=excluded.updated_at, json=excluded.json
|
|
33
36
|
`).run(task.id, task.updatedAt, JSON.stringify(task));
|
|
34
37
|
}
|
|
38
|
+
/** The tick's whole question, asked of the index rather than of every
|
|
39
|
+
* document: a task with no next run (disabled, archived, manual) is not in
|
|
40
|
+
* it, so an idle second reads no row. */
|
|
41
|
+
listDueTasks(now) {
|
|
42
|
+
return this.#many("SELECT json FROM tasks WHERE next_run_at IS NOT NULL AND next_run_at <= ?", now);
|
|
43
|
+
}
|
|
35
44
|
listRuns(taskId, limit = 50, offset = 0) {
|
|
36
45
|
return this.#many(`
|
|
37
46
|
SELECT json FROM task_runs WHERE task_id = ?
|
|
@@ -42,13 +51,56 @@ export class TaskStore {
|
|
|
42
51
|
return this.#one("SELECT json FROM task_runs WHERE id = ?", id);
|
|
43
52
|
}
|
|
44
53
|
saveRun(run) {
|
|
45
|
-
this.
|
|
54
|
+
this.sql(`
|
|
46
55
|
INSERT INTO task_runs(id, task_id, queued_at, state, callback_state, json)
|
|
47
56
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
48
57
|
ON CONFLICT(id) DO UPDATE SET
|
|
49
58
|
state=excluded.state, callback_state=excluded.callback_state, json=excluded.json
|
|
50
59
|
`).run(run.id, run.taskId, run.queuedAt, run.state, run.callbackState, JSON.stringify(run));
|
|
51
60
|
}
|
|
61
|
+
/** The global list: filters precede the limit, and the id breaks timestamp
|
|
62
|
+
* ties so a page boundary never repeats or skips a row. The statement is
|
|
63
|
+
* built per call and not cached — the filter set makes it. */
|
|
64
|
+
queryRuns(query = {}) {
|
|
65
|
+
const where = [];
|
|
66
|
+
const params = [];
|
|
67
|
+
const add = (sql, value) => {
|
|
68
|
+
if (value !== undefined) {
|
|
69
|
+
where.push(sql);
|
|
70
|
+
params.push(value);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
add("r.state = ?", query.state);
|
|
74
|
+
add("r.task_id = ?", query.taskId);
|
|
75
|
+
add("json_extract(r.json, '$.triggerSource') = ?", query.source);
|
|
76
|
+
add("r.queued_at >= ?", query.since);
|
|
77
|
+
add("r.queued_at <= ?", query.until);
|
|
78
|
+
// Keep this predicate identical to migration 17's partial index.
|
|
79
|
+
if (!query.showUnmatched)
|
|
80
|
+
where.push("NOT (r.state = 'succeeded' AND json_extract(r.json, '$.matched') IS 0)");
|
|
81
|
+
if (query.cursor) {
|
|
82
|
+
where.push("(r.queued_at, r.id) < (?, ?)");
|
|
83
|
+
params.push(query.cursor.queuedAt, query.cursor.id);
|
|
84
|
+
}
|
|
85
|
+
const limit = clamp(query.limit ?? 50, 200);
|
|
86
|
+
const rows = this.db.prepare(`
|
|
87
|
+
SELECT r.json, g.callback_state AS group_callback_state,
|
|
88
|
+
(SELECT m.id FROM task_messages m WHERE m.run_id = r.id
|
|
89
|
+
AND m.state IN ('pending', 'delivered') AND json_extract(m.json, '$.kind') = 'decision'
|
|
90
|
+
ORDER BY m.created_at, m.id LIMIT 1) AS decision_id
|
|
91
|
+
FROM task_runs r LEFT JOIN task_groups g ON g.id = json_extract(r.json, '$.groupId')
|
|
92
|
+
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
|
|
93
|
+
ORDER BY r.queued_at DESC, r.id DESC LIMIT ?
|
|
94
|
+
`).all(...params, limit + 1);
|
|
95
|
+
const runs = rows.slice(0, limit).map((row) => ({
|
|
96
|
+
...JSON.parse(row.json), pendingDecisionId: row.decision_id, groupCallbackState: row.group_callback_state,
|
|
97
|
+
}));
|
|
98
|
+
const last = runs.at(-1);
|
|
99
|
+
return {
|
|
100
|
+
runs,
|
|
101
|
+
nextCursor: rows.length > limit && last ? { queuedAt: last.queuedAt, id: last.id } : null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
52
104
|
listRecentRuns(limit = 100) {
|
|
53
105
|
return this.#many("SELECT json FROM task_runs ORDER BY queued_at DESC LIMIT ?", clamp(limit, 500));
|
|
54
106
|
}
|
|
@@ -60,7 +112,7 @@ export class TaskStore {
|
|
|
60
112
|
`, rootRunId, clamp(limit, 500));
|
|
61
113
|
}
|
|
62
114
|
countActiveRuns() {
|
|
63
|
-
const row = this.
|
|
115
|
+
const row = this.sql("SELECT COUNT(*) AS n FROM task_runs WHERE state IN ('queued', 'running')").get();
|
|
64
116
|
return row.n;
|
|
65
117
|
}
|
|
66
118
|
findActiveRun(taskId) {
|
|
@@ -77,6 +129,20 @@ export class TaskStore {
|
|
|
77
129
|
ORDER BY CASE state WHEN 'running' THEN 0 ELSE 1 END, queued_at DESC LIMIT 1
|
|
78
130
|
`, sessionId);
|
|
79
131
|
}
|
|
132
|
+
/** How many background runs each session has in flight, for a list that
|
|
133
|
+
* draws one dot per row: the state column narrows the scan to the handful
|
|
134
|
+
* of live runs, and no row's JSON is parsed. */
|
|
135
|
+
countActiveBackgroundRunsBySession() {
|
|
136
|
+
const rows = this.sql(`
|
|
137
|
+
SELECT json_extract(json, '$.invokedBySessionId') AS session_id, COUNT(*) AS n
|
|
138
|
+
FROM task_runs
|
|
139
|
+
WHERE state IN ('queued', 'running')
|
|
140
|
+
AND json_extract(json, '$.background') = 1
|
|
141
|
+
AND json_extract(json, '$.invokedBySessionId') IS NOT NULL
|
|
142
|
+
GROUP BY session_id
|
|
143
|
+
`).all();
|
|
144
|
+
return new Map(rows.map((row) => [row.session_id, row.n]));
|
|
145
|
+
}
|
|
80
146
|
listRunsForSession(sessionId, limit = 50) {
|
|
81
147
|
return this.#many(`
|
|
82
148
|
SELECT json FROM task_runs
|
|
@@ -85,7 +151,7 @@ export class TaskStore {
|
|
|
85
151
|
`, sessionId, clamp(limit, 200));
|
|
86
152
|
}
|
|
87
153
|
saveGroup(group) {
|
|
88
|
-
this.
|
|
154
|
+
this.sql(`
|
|
89
155
|
INSERT INTO task_groups(id, created_at, callback_state, finished_at, json)
|
|
90
156
|
VALUES (?, ?, ?, ?, ?)
|
|
91
157
|
ON CONFLICT(id) DO UPDATE SET
|
|
@@ -107,7 +173,7 @@ export class TaskStore {
|
|
|
107
173
|
`, now);
|
|
108
174
|
}
|
|
109
175
|
saveMessage(message) {
|
|
110
|
-
this.
|
|
176
|
+
this.sql(`
|
|
111
177
|
INSERT INTO task_messages(id, run_id, state, created_at, json)
|
|
112
178
|
VALUES (?, ?, ?, ?, ?)
|
|
113
179
|
ON CONFLICT(id) DO UPDATE SET state=excluded.state, json=excluded.json
|
|
@@ -122,9 +188,20 @@ export class TaskStore {
|
|
|
122
188
|
listRecentMessages(since) {
|
|
123
189
|
return this.#many("SELECT json FROM task_messages WHERE created_at >= ? ORDER BY created_at DESC LIMIT 200", since);
|
|
124
190
|
}
|
|
125
|
-
/** Messages whose injection never landed
|
|
191
|
+
/** Messages whose injection never landed, each beside the run it belongs to:
|
|
192
|
+
* the delivery sweep needs both, and fetching the run per message made one
|
|
193
|
+
* sweep cost a query per undelivered message. A message whose run is gone
|
|
194
|
+
* comes back with `run: undefined` — the sweep drops it. */
|
|
126
195
|
listUndeliveredMessages() {
|
|
127
|
-
|
|
196
|
+
const rows = this.sql(`
|
|
197
|
+
SELECT m.json AS json, r.json AS run_json
|
|
198
|
+
FROM task_messages m LEFT JOIN task_runs r ON r.id = m.run_id
|
|
199
|
+
WHERE m.state IN ('pending', 'failed') ORDER BY m.created_at
|
|
200
|
+
`).all();
|
|
201
|
+
return rows.map((row) => ({
|
|
202
|
+
message: JSON.parse(row.json),
|
|
203
|
+
run: row.run_json === null ? undefined : JSON.parse(row.run_json),
|
|
204
|
+
}));
|
|
128
205
|
}
|
|
129
206
|
/** Decisions are excluded: they have no timeout and stay answerable across
|
|
130
207
|
* restarts — a reply to a terminal run resumes it. */
|
package/dist/tasks/tool.js
CHANGED
|
@@ -78,7 +78,6 @@ const DraftSchema = Type.Object({
|
|
|
78
78
|
type: Type.Literal("agent"),
|
|
79
79
|
session: Type.Union([
|
|
80
80
|
Type.Object({ mode: Type.Literal("fresh"), cwd: Type.String() }),
|
|
81
|
-
Type.Object({ mode: Type.Literal("fork"), cwd: Type.Optional(Type.String()) }),
|
|
82
81
|
Type.Object({ mode: Type.Literal("reuse"), sessionId: Type.String() }),
|
|
83
82
|
]),
|
|
84
83
|
prompt: Type.String(),
|
|
@@ -102,7 +101,7 @@ export function taskToolSpec(execute) {
|
|
|
102
101
|
return {
|
|
103
102
|
name: "task",
|
|
104
103
|
label: "Pier Task",
|
|
105
|
-
description: "Manage durable Pier tasks and subagents. Agent tasks
|
|
104
|
+
description: "Manage durable Pier tasks and subagents. Agent tasks run in a fresh session or a reused one. Run executes a stored task by task_id, a one-shot subagent from an inline task draft, or a core-joined fan-out via tasks[] with join all|first. Get accepts run_id, group_id, or task_id for that task's recent runs. Every operation returns immediately: results, group joins, and decision replies arrive as callback messages. 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).",
|
|
106
105
|
parameters: Type.Object({
|
|
107
106
|
operation: strEnum("list", "create", "update", "run", "get", "cancel", "steer", "follow_up", "resume", "contact", "reply", "models"),
|
|
108
107
|
task_id: Type.Optional(Type.String()),
|
|
@@ -111,7 +110,7 @@ export function taskToolSpec(execute) {
|
|
|
111
110
|
message_id: Type.Optional(Type.String()),
|
|
112
111
|
message: Type.Optional(Type.String()),
|
|
113
112
|
reason: Type.Optional(strEnum("progress", "decision")),
|
|
114
|
-
session_mode: Type.Optional(strEnum("fresh"
|
|
113
|
+
session_mode: Type.Optional(strEnum("fresh")),
|
|
115
114
|
task: Type.Optional(DraftSchema),
|
|
116
115
|
// The same draft again, spelled out, cost more tokens in every session
|
|
117
116
|
// than the whole rest of this contract. One copy is the guidance; this
|
|
@@ -173,7 +172,12 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
|
|
|
173
172
|
const task = draft
|
|
174
173
|
? await resolveDraft(definitions, draft, active, callerSessionId)
|
|
175
174
|
: resolveStored(definitions, input.task_id, active);
|
|
176
|
-
|
|
175
|
+
// Same as the HTTP route: a named mode the schema no longer offers is
|
|
176
|
+
// answered, not quietly swapped for the definition's own policy.
|
|
177
|
+
if (input.session_mode !== undefined && input.session_mode !== "fresh") {
|
|
178
|
+
throw new Error(`unsupported session_mode: ${String(input.session_mode)}`);
|
|
179
|
+
}
|
|
180
|
+
const sessionMode = input.session_mode;
|
|
177
181
|
let callbackSessionId = input.callback === "none" ? null : callerSessionId;
|
|
178
182
|
if (!active && callbackSessionId && typeof input.callback_session_id === "string") {
|
|
179
183
|
callbackSessionId = requiredString(input.callback_session_id, "callback_session_id");
|
package/dist/tools.js
CHANGED
|
@@ -188,6 +188,10 @@ export function prependPath(env = process.env, bin = toolsBin()) {
|
|
|
188
188
|
mkdirSync(bin, { recursive: true }); // a PATH entry that does not exist is a shell's problem
|
|
189
189
|
env.PATH = current ? `${bin}${delimiter}${current}` : bin;
|
|
190
190
|
}
|
|
191
|
+
/** How long `ubix list --json` stays usable for `status()`: long enough that
|
|
192
|
+
* one Console page open spawns it once, short enough that an install done
|
|
193
|
+
* outside Pier shows up while the operator is still looking at the page. */
|
|
194
|
+
const LIST_TTL_MS = 3_000;
|
|
191
195
|
/** `stale` is heartbeat age, never how long the work has taken; `wait` is what
|
|
192
196
|
* a waiter gives a live holder before giving up with a reason. */
|
|
193
197
|
const LOCK_TIMING = { heartbeatMs: 5_000, staleMs: 30_000, waitMs: 20 * 60_000, pollMs: 200 };
|
|
@@ -606,7 +610,15 @@ export class ManagedTools {
|
|
|
606
610
|
this.#lock ??= new SyncLock(this.#db());
|
|
607
611
|
return this.#lock.run(async (fence) => {
|
|
608
612
|
const { tools, customTools } = read();
|
|
609
|
-
|
|
613
|
+
// This run is the only thing here that changes what `list` answers, so
|
|
614
|
+
// the memo `status()` reads is dropped on both sides of it.
|
|
615
|
+
this.#listed = undefined;
|
|
616
|
+
try {
|
|
617
|
+
return await this.#converge(tools, customTools, fence);
|
|
618
|
+
}
|
|
619
|
+
finally {
|
|
620
|
+
this.#listed = undefined;
|
|
621
|
+
}
|
|
610
622
|
});
|
|
611
623
|
}
|
|
612
624
|
async #converge(enabled, custom, fence) {
|
|
@@ -698,7 +710,7 @@ export class ManagedTools {
|
|
|
698
710
|
return base;
|
|
699
711
|
let states;
|
|
700
712
|
try {
|
|
701
|
-
states = await this.#
|
|
713
|
+
states = await this.#listedTools();
|
|
702
714
|
}
|
|
703
715
|
catch (err) {
|
|
704
716
|
// The page says why it cannot answer rather than answering wrongly: a
|
|
@@ -733,6 +745,24 @@ export class ManagedTools {
|
|
|
733
745
|
});
|
|
734
746
|
});
|
|
735
747
|
}
|
|
748
|
+
/** What `ubix list --json` last said, retained for LIST_TTL_MS. It is a
|
|
749
|
+
* subprocess, and the Console asks for the catalog on every settings read —
|
|
750
|
+
* one page open is several, each of which spawned its own ubix. A failed
|
|
751
|
+
* read is not retained: it is not an answer to hand the next caller for
|
|
752
|
+
* three seconds. */
|
|
753
|
+
#listed;
|
|
754
|
+
#listedTools() {
|
|
755
|
+
const now = Date.now();
|
|
756
|
+
if (this.#listed && now - this.#listed.at < LIST_TTL_MS)
|
|
757
|
+
return this.#listed.states;
|
|
758
|
+
const states = this.#states(this.ubixPath, this.#env(), ["list", "--json"]);
|
|
759
|
+
void states.catch(() => {
|
|
760
|
+
if (this.#listed?.states === states)
|
|
761
|
+
this.#listed = undefined;
|
|
762
|
+
});
|
|
763
|
+
this.#listed = { at: now, states };
|
|
764
|
+
return states;
|
|
765
|
+
}
|
|
736
766
|
/** Pier's ubix config and state, never the operator's. `UBIX_CONFIG_DIR` /
|
|
737
767
|
* `UBIX_DATA_DIR` name the directories that hold config.toml / state.toml
|
|
738
768
|
* directly — not XDG parents, which every child ubix spawns (uv, fnm,
|
package/dist/web/auth.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { createHash, randomBytes, randomInt, scryptSync, timingSafeEqual } from "node:crypto";
|
|
21
21
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
22
22
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
23
|
-
import { pierDb, transact } from "../db.js";
|
|
23
|
+
import { pierDb, statements, transact } from "../db.js";
|
|
24
24
|
import { logger } from "../log.js";
|
|
25
25
|
const log = logger("auth");
|
|
26
26
|
const COOKIE = "pier_session";
|
|
@@ -67,9 +67,13 @@ function generatePassword() {
|
|
|
67
67
|
*/
|
|
68
68
|
export class AuthStore {
|
|
69
69
|
#db;
|
|
70
|
+
/** Compiled once each: `check()` runs two of these on every request that
|
|
71
|
+
* carries a cookie, which is every request the workbench makes. */
|
|
72
|
+
#sql;
|
|
70
73
|
#revokeListeners = new Set();
|
|
71
74
|
constructor(db = pierDb(), print = (m) => log.info(m)) {
|
|
72
75
|
this.#db = db;
|
|
76
|
+
this.#sql = statements(db);
|
|
73
77
|
let row = this.#row();
|
|
74
78
|
if (!row) {
|
|
75
79
|
const password = generatePassword();
|
|
@@ -81,8 +85,7 @@ export class AuthStore {
|
|
|
81
85
|
// in one transaction: half of this leaves a new password and live old
|
|
82
86
|
// cookies, which is the state recovery exists to end.
|
|
83
87
|
transact(this.#db, () => {
|
|
84
|
-
this.#
|
|
85
|
-
.prepare("INSERT INTO auth(id, salt, hash, created_at) VALUES (1, ?, ?, ?)")
|
|
88
|
+
this.#sql("INSERT INTO auth(id, salt, hash, created_at) VALUES (1, ?, ?, ?)")
|
|
86
89
|
.run(salt, hash(password, salt), Date.now());
|
|
87
90
|
this.#dropSessions();
|
|
88
91
|
});
|
|
@@ -96,14 +99,13 @@ export class AuthStore {
|
|
|
96
99
|
this.sweep();
|
|
97
100
|
}
|
|
98
101
|
#row() {
|
|
99
|
-
return this.#
|
|
100
|
-
.prepare("SELECT salt, hash, created_at AS createdAt FROM auth WHERE id = 1")
|
|
102
|
+
return this.#sql("SELECT salt, hash, created_at AS createdAt FROM auth WHERE id = 1")
|
|
101
103
|
.get();
|
|
102
104
|
}
|
|
103
105
|
/** Every signed-in browser at once. Private: the callers that mean it also
|
|
104
106
|
* have to tell the listeners, and `revoke(ALL)` is that pair in public. */
|
|
105
107
|
#dropSessions() {
|
|
106
|
-
this.#
|
|
108
|
+
this.#sql("DELETE FROM web_sessions").run();
|
|
107
109
|
}
|
|
108
110
|
/** Sessions nobody may use any more, deleted rather than merely refused: a
|
|
109
111
|
* row is what a push subscription hangs off, so "expired" has to become
|
|
@@ -111,8 +113,7 @@ export class AuthStore {
|
|
|
111
113
|
* boot and whenever somebody signs in — the two moments the process has a
|
|
112
114
|
* reason to look at this table at all. */
|
|
113
115
|
sweep() {
|
|
114
|
-
const swept = this.#
|
|
115
|
-
.prepare("DELETE FROM web_sessions WHERE seen_at <= ? RETURNING id")
|
|
116
|
+
const swept = this.#sql("DELETE FROM web_sessions WHERE seen_at <= ? RETURNING id")
|
|
116
117
|
.all(Date.now() - TTL_MS);
|
|
117
118
|
for (const row of swept)
|
|
118
119
|
this.#revoked(row.id);
|
|
@@ -135,8 +136,7 @@ export class AuthStore {
|
|
|
135
136
|
// Credential and sessions change together or not at all — a crash between
|
|
136
137
|
// the two writes is exactly the state "everyone signs in again" denies.
|
|
137
138
|
transact(this.#db, () => {
|
|
138
|
-
this.#
|
|
139
|
-
.prepare("UPDATE auth SET salt = ?, hash = ?, created_at = ? WHERE id = 1")
|
|
139
|
+
this.#sql("UPDATE auth SET salt = ?, hash = ?, created_at = ? WHERE id = 1")
|
|
140
140
|
.run(salt, hash(password, salt), Date.now());
|
|
141
141
|
this.#dropSessions();
|
|
142
142
|
});
|
|
@@ -150,10 +150,8 @@ export class AuthStore {
|
|
|
150
150
|
// name, and the 256-bit token is the only part that has to resist guessing.
|
|
151
151
|
const id = randomBytes(9).toString("base64url");
|
|
152
152
|
const token = randomBytes(32).toString("base64url");
|
|
153
|
-
this.#
|
|
154
|
-
|
|
155
|
-
" VALUES (?, ?, ?, ?, ?, ?)")
|
|
156
|
-
.run(id, digest(token), now, now, ip, agent.slice(0, 200));
|
|
153
|
+
this.#sql("INSERT INTO web_sessions(id, token_hash, created_at, seen_at, ip, agent)" +
|
|
154
|
+
" VALUES (?, ?, ?, ?, ?, ?)").run(id, digest(token), now, now, ip, agent.slice(0, 200));
|
|
157
155
|
return `${id}.${token}`;
|
|
158
156
|
}
|
|
159
157
|
/**
|
|
@@ -166,9 +164,7 @@ export class AuthStore {
|
|
|
166
164
|
const [id, token] = (cookie ?? "").split(".");
|
|
167
165
|
if (!id || !token)
|
|
168
166
|
return undefined;
|
|
169
|
-
const row = this.#
|
|
170
|
-
.prepare("SELECT token_hash AS tokenHash, seen_at AS seenAt FROM web_sessions WHERE id = ?")
|
|
171
|
-
.get(id);
|
|
167
|
+
const row = this.#sql("SELECT token_hash AS tokenHash, seen_at AS seenAt FROM web_sessions WHERE id = ?").get(id);
|
|
172
168
|
const now = Date.now();
|
|
173
169
|
// One clock: last use is the deadline, so there is no second column that
|
|
174
170
|
// can disagree with it about when this session ends. An expired row is
|
|
@@ -184,7 +180,7 @@ export class AuthStore {
|
|
|
184
180
|
return undefined;
|
|
185
181
|
if (now - row.seenAt < TOUCH_MS)
|
|
186
182
|
return { id, renewed: false };
|
|
187
|
-
this.#
|
|
183
|
+
this.#sql("UPDATE web_sessions SET seen_at = ? WHERE id = ?").run(now, id);
|
|
188
184
|
return { id, renewed: true };
|
|
189
185
|
}
|
|
190
186
|
/** Sign out one browser, or every one of them (`ALL`). Listeners hear the
|
|
@@ -193,16 +189,14 @@ export class AuthStore {
|
|
|
193
189
|
if (id === ALL)
|
|
194
190
|
this.#dropSessions();
|
|
195
191
|
else
|
|
196
|
-
this.#
|
|
192
|
+
this.#sql("DELETE FROM web_sessions WHERE id = ?").run(id);
|
|
197
193
|
this.#revoked(id);
|
|
198
194
|
}
|
|
199
195
|
/** Signed-in browsers, most recently seen first. Never the token — the list
|
|
200
196
|
* is shown to whoever is signed in, and it is not a set of credentials. */
|
|
201
197
|
list() {
|
|
202
|
-
return this.#
|
|
203
|
-
|
|
204
|
-
" FROM web_sessions WHERE seen_at > ? ORDER BY seen_at DESC")
|
|
205
|
-
.all(Date.now() - TTL_MS);
|
|
198
|
+
return this.#sql("SELECT id, created_at AS createdAt, seen_at AS seenAt, ip, agent" +
|
|
199
|
+
" FROM web_sessions WHERE seen_at > ? ORDER BY seen_at DESC").all(Date.now() - TTL_MS);
|
|
206
200
|
}
|
|
207
201
|
/** A long-lived authenticated surface closes itself when a cookie is
|
|
208
202
|
* revoked. The store and listeners share the process lifetime. */
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// HTTP presentation of the injected configuration subscription. The sole
|
|
2
|
+
// public route is registered before auth; every management action is after it.
|
|
3
|
+
import { logger } from "../log.js";
|
|
4
|
+
const log = logger("config-sync");
|
|
5
|
+
export function registerConfigShareRoute(app, sync) {
|
|
6
|
+
app.get("/config-sync/:token", async (c) => {
|
|
7
|
+
c.header("cache-control", "no-store");
|
|
8
|
+
c.header("x-frame-options", "DENY");
|
|
9
|
+
c.header("referrer-policy", "no-referrer");
|
|
10
|
+
if (c.req.method !== "GET") {
|
|
11
|
+
c.header("allow", "GET");
|
|
12
|
+
return c.body(null, 405);
|
|
13
|
+
}
|
|
14
|
+
const token = c.req.param("token");
|
|
15
|
+
if (!/^[a-f0-9]{64}$/.test(token))
|
|
16
|
+
return c.json({ error: "not found" }, 404);
|
|
17
|
+
try {
|
|
18
|
+
const published = await sync.published(token);
|
|
19
|
+
if (!published)
|
|
20
|
+
return c.json({ error: "not found" }, 404);
|
|
21
|
+
c.header("etag", published.etag);
|
|
22
|
+
c.header("x-content-type-options", "nosniff");
|
|
23
|
+
const tags = c.req.header("if-none-match")?.split(",").map((value) => value.trim().replace(/^W\//, ""));
|
|
24
|
+
if (tags?.includes(published.etag) || tags?.includes("*"))
|
|
25
|
+
return c.body(null, 304);
|
|
26
|
+
c.header("content-type", "application/json; charset=utf-8");
|
|
27
|
+
return c.body(published.body);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Never let the app's path logger print a capability URL.
|
|
31
|
+
log.error("Could not export shared configuration");
|
|
32
|
+
return c.json({ error: "Could not export configuration" }, 500);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
// Reject other verbs here so neither auth redirects nor request-path logs
|
|
36
|
+
// can carry this capability to another surface.
|
|
37
|
+
app.all("/config-sync/:token", (c) => {
|
|
38
|
+
c.header("cache-control", "no-store");
|
|
39
|
+
c.header("allow", "GET");
|
|
40
|
+
return c.body(null, 405);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export function registerConfigSyncRoutes(app, deps) {
|
|
44
|
+
app.get("/api/config-sync", (c) => {
|
|
45
|
+
c.header("cache-control", "no-store");
|
|
46
|
+
return c.json(deps.status());
|
|
47
|
+
});
|
|
48
|
+
app.post("/api/config-sync", async (c) => {
|
|
49
|
+
c.header("cache-control", "no-store");
|
|
50
|
+
const body = await c.req.json().catch(() => null);
|
|
51
|
+
if (!body || typeof body.action !== "string")
|
|
52
|
+
return c.json({ error: "action required" }, 400);
|
|
53
|
+
try {
|
|
54
|
+
let result;
|
|
55
|
+
switch (body.action) {
|
|
56
|
+
case "publish":
|
|
57
|
+
await deps.sync.publish();
|
|
58
|
+
break;
|
|
59
|
+
case "revoke":
|
|
60
|
+
await deps.sync.revoke();
|
|
61
|
+
break;
|
|
62
|
+
case "subscribe": {
|
|
63
|
+
if (typeof body.url !== "string")
|
|
64
|
+
throw new Error("Source URL required");
|
|
65
|
+
result = await deps.sync.subscribe(body.url);
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
case "pause":
|
|
69
|
+
await deps.sync.pause();
|
|
70
|
+
break;
|
|
71
|
+
case "sync":
|
|
72
|
+
result = await deps.run();
|
|
73
|
+
break;
|
|
74
|
+
default: return c.json({ error: "Unknown configuration sync action" }, 400);
|
|
75
|
+
}
|
|
76
|
+
await deps.reconcile();
|
|
77
|
+
return c.json({ ...deps.status(), ...(result ? { result } : {}) });
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
// Application can succeed while reload fails; still reconcile the owned
|
|
81
|
+
// task to the persisted subscription switch in that case.
|
|
82
|
+
try {
|
|
83
|
+
await deps.reconcile();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
log.error("Could not reconcile configuration sync task");
|
|
87
|
+
}
|
|
88
|
+
return c.json({ error: err instanceof Error ? err.message : "Configuration sync failed", status: deps.status() }, 409);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The agent files a scope is configured by — Pi's own config, skills and
|
|
2
|
+
// extensions — read and written through the ConfigStore, never as paths. A
|
|
3
|
+
// scope is "global" or a project cwd Pi already knows, which is why this is
|
|
4
|
+
// the one filesystem-shaped surface that does not go through web/fs.ts: it
|
|
5
|
+
// never takes a path from the browser at all.
|
|
6
|
+
import { guarded } from "./route.js";
|
|
7
|
+
export function registerConfigRoutes(app, { factory, config, onConfigWritten }) {
|
|
8
|
+
// Scope comes from the client as "global" or a project cwd; only cwds Pi
|
|
9
|
+
// already knows (the session list) are accepted — never an arbitrary path.
|
|
10
|
+
const parseScope = async (raw) => {
|
|
11
|
+
if (!raw || raw === "global")
|
|
12
|
+
return { kind: "global" };
|
|
13
|
+
const known = await factory.list();
|
|
14
|
+
return known.some((s) => s.cwd === raw) ? { kind: "project", cwd: raw } : null;
|
|
15
|
+
};
|
|
16
|
+
app.get("/api/config", async (c) => {
|
|
17
|
+
c.header("cache-control", "no-store");
|
|
18
|
+
const scope = await parseScope(c.req.query("scope"));
|
|
19
|
+
if (!scope)
|
|
20
|
+
return c.json({ error: "unknown scope" }, 400);
|
|
21
|
+
return c.json({
|
|
22
|
+
// Where this scope's files live on disk — the UI labels "Global" with it.
|
|
23
|
+
dir: scope.kind === "global" ? config.globalDir : scope.cwd,
|
|
24
|
+
files: await config.listFiles(scope),
|
|
25
|
+
resources: await config.listResources(scope),
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
guarded(app, "GET", "/api/config/files/:name", 400, async (c) => {
|
|
29
|
+
c.header("cache-control", "no-store");
|
|
30
|
+
const scope = await parseScope(c.req.query("scope"));
|
|
31
|
+
if (!scope)
|
|
32
|
+
return c.json({ error: "unknown scope" }, 400);
|
|
33
|
+
return c.json({ content: await config.readFile(scope, c.req.param("name")) });
|
|
34
|
+
});
|
|
35
|
+
guarded(app, "PUT", "/api/config/files/:name", 400, async (c) => {
|
|
36
|
+
const scope = await parseScope(c.req.query("scope"));
|
|
37
|
+
if (!scope)
|
|
38
|
+
return c.json({ error: "unknown scope" }, 400);
|
|
39
|
+
const body = await c.req.json().catch(() => null);
|
|
40
|
+
if (typeof body?.content !== "string" || typeof body?.expected !== "string") {
|
|
41
|
+
return c.json({ error: "content and expected content required" }, 400);
|
|
42
|
+
}
|
|
43
|
+
const name = c.req.param("name");
|
|
44
|
+
await config.writeFile(scope, name, body.content, body.expected);
|
|
45
|
+
onConfigWritten?.();
|
|
46
|
+
return c.json({ ok: true, content: await config.readFile(scope, name) });
|
|
47
|
+
});
|
|
48
|
+
// Resource names may contain slashes — query params, not path params.
|
|
49
|
+
guarded(app, "GET", "/api/config/resource", 400, async (c) => {
|
|
50
|
+
c.header("cache-control", "no-store");
|
|
51
|
+
const scope = await parseScope(c.req.query("scope"));
|
|
52
|
+
if (!scope)
|
|
53
|
+
return c.json({ error: "unknown scope" }, 400);
|
|
54
|
+
const kind = c.req.query("kind");
|
|
55
|
+
const name = c.req.query("name");
|
|
56
|
+
if ((kind !== "extensions" && kind !== "skills") || !name) {
|
|
57
|
+
return c.json({ error: "kind and name required" }, 400);
|
|
58
|
+
}
|
|
59
|
+
return c.json({ content: await config.readResource(scope, kind, name) });
|
|
60
|
+
});
|
|
61
|
+
}
|