@sigrun/sigrun 0.6.1 → 0.7.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/CHANGELOG.md +92 -0
- package/README.md +105 -18
- package/dist/blocks/build-stamp.json +2 -2
- package/dist/blocks/schema.js +5 -0
- package/dist/cli.js +85 -22
- package/dist/curation/host.js +46 -21
- package/dist/curation/migrate.js +96 -0
- package/dist/curation/overlays.js +52 -16
- package/dist/curation/paths.js +22 -0
- package/dist/curation/runs.js +34 -17
- package/dist/curation/skill-tool.js +34 -9
- package/dist/curation/store.js +382 -254
- package/dist/curation/targets.js +21 -0
- package/dist/curation/wrap.js +1 -6
- package/dist/extension.d.ts +12 -3
- package/dist/harness/boot.js +143 -6
- package/dist/harness/event-bus.js +19 -0
- package/dist/harness/loader.js +22 -0
- package/dist/harness/reserved-sections.js +2 -0
- package/dist/harness/seam-host.js +71 -16
- package/dist/harness/workspace.js +34 -1
- package/dist/history/git.js +707 -0
- package/dist/history/trailers.js +145 -0
- package/dist/history/workspace-history.js +47 -0
- package/dist/jobs/index.js +5 -3
- package/dist/jobs/reconcile.js +2 -3
- package/dist/jobs/state-dir.js +9 -0
- package/dist/jobs/transfer.js +6 -6
- package/dist/sandbox/grants.js +11 -0
- package/dist/sandbox/launch.js +19 -9
- package/dist/sandbox/policy.js +6 -1
- package/dist/serve.js +3 -0
- package/dist/server/auth/proxy-only.js +5 -4
- package/dist/server/core-views/index.js +26 -0
- package/dist/server/core-views/jobs.js +661 -0
- package/dist/server/core-views/skills-tree.js +628 -0
- package/dist/server/core-views/skills.js +1090 -0
- package/dist/server/index.js +88 -0
- package/dist/server/jobs-access.js +62 -0
- package/dist/server/notifications.js +24 -12
- package/dist/server/routes/extensions.js +9 -1
- package/dist/server/routes/jobs.js +19 -52
- package/dist/server/routes/meta.js +9 -0
- package/dist/server/routes/onboarding.js +13 -1
- package/dist/server/surfaces/service.js +38 -0
- package/dist/skills/index.js +1 -1
- package/dist/skills/roots.js +301 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +8 -0
- package/node_modules/@sigrun/shared/dist/auth.d.ts +22 -0
- package/node_modules/@sigrun/shared/dist/auth.js +26 -0
- package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +1 -1
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +15 -0
- package/node_modules/@sigrun/shared/dist/model-label.d.ts +29 -0
- package/node_modules/@sigrun/shared/dist/model-label.js +55 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +28 -0
- package/node_modules/@sigrun/shared/package.json +9 -1
- package/package.json +7 -7
- package/webui/dist/assets/index-BI5k-Oci.js +55 -0
- package/webui/dist/assets/index-EGluyRXk.css +1 -0
- package/webui/dist/favicon.svg +7 -5
- package/webui/dist/index.html +3 -3
- package/webui/dist/assets/index-BR0di0OG.css +0 -1
- package/webui/dist/assets/index-BzrA1T4g.js +0 -55
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `core/skills` view (tabs+skills Task 12): every skill root the
|
|
3
|
+
* deployment serves — Workspace / Learned / Config dir (the
|
|
4
|
+
* `<configDir>/skills` root) / one group per package (`· <pkg>`) /
|
|
5
|
+
* Deleted (whole-branch review I5: the spec's label semantics) — with
|
|
6
|
+
* owned-skill CRUD, git history with per-file diffs, and overlays for
|
|
7
|
+
* the read-only roots.
|
|
8
|
+
*
|
|
9
|
+
* - The caller sees THEIR OWN roots: workspace skills under
|
|
10
|
+
* `workspaceRoot(user)`, learned skills + overlays + the learned
|
|
11
|
+
* history through `curationOf(user)` (Task 9), the workspace history
|
|
12
|
+
* through `workspaceHistoryFor(user)` (Task 10), and the deployment's
|
|
13
|
+
* shared roots (`userDir`, the package roots) through Task 11's
|
|
14
|
+
* `listAllRoots` / `readSkillFile` / `listSupportFiles`. A principal
|
|
15
|
+
* without a user gets access `none`.
|
|
16
|
+
* - The tree is DATA-BOUND (the Task 4 bump contract): every action
|
|
17
|
+
* ends with `bumpAllInstances("core","skills")`; the tree itself
|
|
18
|
+
* carries no per-render rows.
|
|
19
|
+
* - Catch-up of outside edits runs on BOTH of the caller's repos at
|
|
20
|
+
* most once per repo per 30 s (an in-memory per-repo timestamp —
|
|
21
|
+
* renders re-run on every data refetch, and the throttle is what
|
|
22
|
+
* keeps "when the tab opens" true without spawning git per render),
|
|
23
|
+
* bounded at 5 s with errors swallowed (a broken repo shows its
|
|
24
|
+
* reason in the history pane; listing and editing still work).
|
|
25
|
+
* - Writes: workspace skills live in the workspace repo
|
|
26
|
+
* (`workspaceHistory.write`, frontmatter + `scanContent` + the name
|
|
27
|
+
* rule, refuse-if-exists); learned skills go through the SkillStore
|
|
28
|
+
* (`apply` create/patch/archive, `restoreVersion`, `pin`); user-dir
|
|
29
|
+
* and package skills are READ-ONLY here — the caller's per-user
|
|
30
|
+
* overlay (`resolveUpstream` → `OverlayStore.put` with the upstream
|
|
31
|
+
* hash) is their only write.
|
|
32
|
+
* - `restoreDeleted` refuses a reused name with "a skill named <name>
|
|
33
|
+
* exists in <root>; rename or delete it first" (Review Focus 5) — and
|
|
34
|
+
* a deleted selection's ONLY action is the *Restore deleted* button
|
|
35
|
+
* that dispatches it (whole-branch review I2: Edit/Delete/restore a
|
|
36
|
+
* version never render there). `restoreVersion` on a workspace skill
|
|
37
|
+
* refuses the same way when the target commit predates the name's
|
|
38
|
+
* newest delete while a live row holds the name (the reused skill's
|
|
39
|
+
* history chain spans the old skill's commits; a plain restore would
|
|
40
|
+
* silently overwrite it).
|
|
41
|
+
*/
|
|
42
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
43
|
+
import { join } from "node:path";
|
|
44
|
+
import { resolveUpstream } from "../../curation/overlays.js";
|
|
45
|
+
import { validSkillName } from "../../curation/names.js";
|
|
46
|
+
import { scanContent } from "../../curation/scan.js";
|
|
47
|
+
import { listAllRoots, listSupportFiles, readSkillFile, } from "../../skills/roots.js";
|
|
48
|
+
import { parseSkillFile } from "../../skills/index.js";
|
|
49
|
+
import { buildSkillsTree, capCommitFiles, parseRowId, rowIdOf, SKILLS_HISTORY_PAGE_SIZE, skillsInstanceKey, skillsParamsOf, skillsNoneTree, historyPage, sidebarRows, } from "./skills-tree.js";
|
|
50
|
+
/** The catch-up contract (the brief, verbatim): once per repo per 30 s, 5 s bound. */
|
|
51
|
+
export const SKILLS_CATCHUP_INTERVAL_MS = 30_000;
|
|
52
|
+
export const SKILLS_CATCHUP_DEADLINE_MS = 5_000;
|
|
53
|
+
/** The one SKILL.md (frontmatter + body) for a write. */
|
|
54
|
+
function skillFileText(name, description, body) {
|
|
55
|
+
const trimmed = body.replace(/^\s+/, "").replace(/\s+$/, "");
|
|
56
|
+
return `---\nname: ${name}\ndescription: ${description}\n---\n\n${trimmed === "" ? "" : `${trimmed}\n`}`;
|
|
57
|
+
}
|
|
58
|
+
/** Whether `payload` is a plain object. */
|
|
59
|
+
function isObject(payload) {
|
|
60
|
+
return typeof payload === "object" && payload !== null && !Array.isArray(payload);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* One string field of the payload. Form dispatches wrap the fields as
|
|
64
|
+
* `{values: {...}, rev}` (the webui FormBlock contract); callout-level
|
|
65
|
+
* buttons dispatch a null payload and their targets ride the instance
|
|
66
|
+
* params. This reads the top level first, then the `values` envelope,
|
|
67
|
+
* so both shapes resolve.
|
|
68
|
+
*/
|
|
69
|
+
function fieldOf(payload, key) {
|
|
70
|
+
if (!isObject(payload))
|
|
71
|
+
return undefined;
|
|
72
|
+
const top = payload[key];
|
|
73
|
+
if (typeof top === "string")
|
|
74
|
+
return top;
|
|
75
|
+
const values = payload.values;
|
|
76
|
+
if (isObject(values)) {
|
|
77
|
+
const nested = values[key];
|
|
78
|
+
if (typeof nested === "string")
|
|
79
|
+
return nested;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
/** The `{itemId}` a collection activation carries. */
|
|
84
|
+
function itemIdOf(payload) {
|
|
85
|
+
return fieldOf(payload, "itemId");
|
|
86
|
+
}
|
|
87
|
+
/** The action result for a thrown error. */
|
|
88
|
+
function failure(error) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
error: { message: error instanceof Error ? error.message : String(error) },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** One skill's combined "is present anywhere" check (the restoreDeleted refusal). */
|
|
95
|
+
function presentRootOf(skills, name) {
|
|
96
|
+
return skills.find((skill) => skill.name === name)?.root;
|
|
97
|
+
}
|
|
98
|
+
/** `git log --format=%H -- <pathspec>` rows, oldest-order irrelevant. */
|
|
99
|
+
async function logShas(history, path, limit) {
|
|
100
|
+
const commits = await history.log({ path, limit });
|
|
101
|
+
return commits.map((commit) => ({ sha: commit.sha, summary: commit.summary }));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Names the workspace repo's log touches that have NO live row AND
|
|
105
|
+
* whose newest commit REMOVED the dir (a deleted skill). A name the
|
|
106
|
+
* log touches that is LIVE again (deleted then re-created) stays
|
|
107
|
+
* live — its newest commit re-adds files, so the newest-summary
|
|
108
|
+
* check excludes it.
|
|
109
|
+
*/
|
|
110
|
+
async function deletedWorkspaceSkills(history, liveNames) {
|
|
111
|
+
const touched = new Set();
|
|
112
|
+
for (const commit of await history.log({ limit: 1000 })) {
|
|
113
|
+
for (const path of commit.paths) {
|
|
114
|
+
const name = path.split("/")[0] ?? "";
|
|
115
|
+
if (name !== "" && validSkillName(name))
|
|
116
|
+
touched.add(name);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const names = [];
|
|
120
|
+
for (const name of touched) {
|
|
121
|
+
if (liveNames.has(name))
|
|
122
|
+
continue;
|
|
123
|
+
const newest = (await history.log({ path: `${name}/`, limit: 1 }))[0];
|
|
124
|
+
if (newest === undefined)
|
|
125
|
+
continue;
|
|
126
|
+
if (newest.summary === `delete ${name}`)
|
|
127
|
+
names.push(name);
|
|
128
|
+
}
|
|
129
|
+
return names.sort();
|
|
130
|
+
}
|
|
131
|
+
/** The newest deletion commit's sha for one workspace skill (`{deletionSha}` row field). */
|
|
132
|
+
async function deletionShaOf(history, name) {
|
|
133
|
+
const newest = (await history.log({ path: `${name}/`, limit: 1 }))[0];
|
|
134
|
+
return newest !== undefined && newest.summary === `delete ${name}`
|
|
135
|
+
? { deletionSha: newest.sha }
|
|
136
|
+
: {};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The newest `delete <name>` commit's sha for one WORKSPACE skill, or
|
|
140
|
+
* undefined when the name was never deleted (whole-branch review I2's
|
|
141
|
+
* reused-name refusal needs the anchor independent of the newest-touch
|
|
142
|
+
* rule {@link deletionShaOf} applies).
|
|
143
|
+
*/
|
|
144
|
+
async function newestDeletionSha(history, name) {
|
|
145
|
+
// A name's commits are few; the cap only bounds a pathological repo.
|
|
146
|
+
for (const commit of await history.log({ path: `${name}/`, limit: 1000 })) {
|
|
147
|
+
if (commit.summary === `delete ${name}`)
|
|
148
|
+
return commit.sha;
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The exact reused-name refusal text (restoreDeleted's existing message,
|
|
154
|
+
* shared with restoreVersion's workspace branch — whole-branch review I2).
|
|
155
|
+
*/
|
|
156
|
+
function reusedNameError(name, root) {
|
|
157
|
+
return `a skill named ${name} exists in ${root}; rename or delete it first`;
|
|
158
|
+
}
|
|
159
|
+
/** One sha's index in a log page (-1 when absent). */
|
|
160
|
+
function pathLogIndex(log, sha) {
|
|
161
|
+
return log.findIndex((one) => one.sha === sha);
|
|
162
|
+
}
|
|
163
|
+
/** The newest commit's sha on one path (the learned archive's restore anchor). */
|
|
164
|
+
async function lastActionSha(history, path) {
|
|
165
|
+
const commits = await logShas(history, path, 1);
|
|
166
|
+
return commits[0]?.sha;
|
|
167
|
+
}
|
|
168
|
+
/** The view surface (`SurfaceDef<"view">`), built over the Task 9/10/11 deps. */
|
|
169
|
+
export function createSkillsView(deps) {
|
|
170
|
+
const now = deps.now ?? (() => new Date());
|
|
171
|
+
// The per-repo catch-up throttle (the brief's once-per-30s rule).
|
|
172
|
+
const lastCatchUp = new Map();
|
|
173
|
+
/** The caller's EFFECTIVE username (review I3): an anonymous
|
|
174
|
+
* principal → undefined (the surface 404s); the userless superuser
|
|
175
|
+
* resolves through {@link SkillsViewDeps.resolveUserlessCaller} to
|
|
176
|
+
* the primary admin when the dep is wired (else it keeps its own
|
|
177
|
+
* undefined scope — the empty-workspace posture). */
|
|
178
|
+
const userOf = (ctx) => deps.resolveUserlessCaller === undefined
|
|
179
|
+
? ctx.principal.user
|
|
180
|
+
: deps.resolveUserlessCaller(ctx.principal);
|
|
181
|
+
/** The caller's shared root listing (workspace/learned scoped to them). */
|
|
182
|
+
const listFor = (user) => {
|
|
183
|
+
const workspace = deps.workspaceRoot(user);
|
|
184
|
+
const learned = deps.learnedDirOf(user);
|
|
185
|
+
return listAllRoots({
|
|
186
|
+
...(workspace === undefined ? {} : { workspace: join(workspace, "skills") }),
|
|
187
|
+
...(learned === undefined ? {} : { learned }),
|
|
188
|
+
userDir: deps.userDir,
|
|
189
|
+
packageRoots: deps.packageRoots(),
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
/** One caller's FULL sidebar: the live rows plus a Deleted-group row
|
|
193
|
+
* for every skill gone from disk whose history still holds it. */
|
|
194
|
+
const rowsFor = async (user) => {
|
|
195
|
+
const skills = await listFor(user);
|
|
196
|
+
const curation = deps.curationOf(user);
|
|
197
|
+
const archived = new Set(curation === undefined
|
|
198
|
+
? []
|
|
199
|
+
: (await curation.store.list()).filter((info) => info.archived).map((info) => info.name));
|
|
200
|
+
const rows = sidebarRows(skills, { deleted: archived });
|
|
201
|
+
// Deleted-group rows: a name with NO live row whose repo log still
|
|
202
|
+
// holds the skill. WORKSPACE: the workspace repo's log names it
|
|
203
|
+
// (its dir is gone for good — a present live name stays live).
|
|
204
|
+
// LEARNED: an archived skill's files moved to curation/archive,
|
|
205
|
+
// OUTSIDE the learned discovery root, so its row is synthesized
|
|
206
|
+
// from the store's archived list (listAllRoots never sees it).
|
|
207
|
+
const liveNames = new Set(rows.map((row) => row.name).filter((name) => typeof name === "string"));
|
|
208
|
+
const workspaceHistory = deps.workspaceHistoryFor(user);
|
|
209
|
+
if (workspaceHistory !== undefined) {
|
|
210
|
+
for (const name of await deletedWorkspaceSkills(workspaceHistory, liveNames)) {
|
|
211
|
+
rows.push({
|
|
212
|
+
id: rowIdOf("workspace", name),
|
|
213
|
+
name,
|
|
214
|
+
description: "",
|
|
215
|
+
group: "deleted",
|
|
216
|
+
...(await deletionShaOf(workspaceHistory, name)),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (curation !== undefined) {
|
|
221
|
+
const infos = await curation.store.list();
|
|
222
|
+
for (const info of infos) {
|
|
223
|
+
if (!info.archived)
|
|
224
|
+
continue;
|
|
225
|
+
const existing = rows.find((one) => typeof one.id === "string" && one.id === rowIdOf("learned", info.name));
|
|
226
|
+
const sha = await lastActionSha(curation.history, `curation/archive/${info.name}`);
|
|
227
|
+
if (existing === undefined) {
|
|
228
|
+
rows.push({
|
|
229
|
+
id: rowIdOf("learned", info.name),
|
|
230
|
+
name: info.name,
|
|
231
|
+
description: info.description,
|
|
232
|
+
group: "deleted",
|
|
233
|
+
...(sha === undefined ? {} : { deletionSha: sha }),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
existing.group = "deleted";
|
|
238
|
+
if (sha !== undefined)
|
|
239
|
+
existing.deletionSha = sha;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return rows;
|
|
244
|
+
};
|
|
245
|
+
/** Catch up ONE repo, throttled (once per 30 s) + deadline-bounded + fail-swallowed. */
|
|
246
|
+
async function catchUpThrottled(history) {
|
|
247
|
+
const last = lastCatchUp.get(history);
|
|
248
|
+
const at = now().getTime();
|
|
249
|
+
if (last !== undefined && at - last < SKILLS_CATCHUP_INTERVAL_MS)
|
|
250
|
+
return;
|
|
251
|
+
lastCatchUp.set(history, at);
|
|
252
|
+
const controller = new AbortController();
|
|
253
|
+
const timer = setTimeout(() => controller.abort(), SKILLS_CATCHUP_DEADLINE_MS);
|
|
254
|
+
void controller;
|
|
255
|
+
try {
|
|
256
|
+
await Promise.race([
|
|
257
|
+
history.catchUp({ actor: { kind: "outside" }, summary: "outside changes" }),
|
|
258
|
+
new Promise((resolve) => setTimeout(resolve, SKILLS_CATCHUP_DEADLINE_MS)),
|
|
259
|
+
]);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// Swallowed by design: a broken repo shows its reason in the
|
|
263
|
+
// history pane (availability below); rendering never fails.
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
clearTimeout(timer);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** The outside-edits catch-up for BOTH of the caller's repos. */
|
|
270
|
+
async function catchUpBoth(user) {
|
|
271
|
+
const repos = [
|
|
272
|
+
deps.curationOf(user)?.history,
|
|
273
|
+
deps.workspaceHistoryFor(user),
|
|
274
|
+
];
|
|
275
|
+
for (const history of repos) {
|
|
276
|
+
if (history === undefined)
|
|
277
|
+
continue;
|
|
278
|
+
await catchUpThrottled(history);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/** The selected skill's history availability (the pane's reason when down). */
|
|
282
|
+
async function availabilityOf(history, missingReason) {
|
|
283
|
+
if (history === undefined)
|
|
284
|
+
return { available: false, reason: missingReason };
|
|
285
|
+
const state = await history.availability();
|
|
286
|
+
return state.ok ? { available: true, reason: null } : { available: false, reason: state.reason };
|
|
287
|
+
}
|
|
288
|
+
/** Resolve the params' selection against the caller's rows. */
|
|
289
|
+
async function resolveSelection(user, params, skills) {
|
|
290
|
+
if (params.skill === undefined)
|
|
291
|
+
return undefined;
|
|
292
|
+
const row = skills.find((skill) => {
|
|
293
|
+
if (skill.name !== params.skill)
|
|
294
|
+
return false;
|
|
295
|
+
if (params.root !== undefined && skill.root !== params.root)
|
|
296
|
+
return false;
|
|
297
|
+
if (params.root === "package" || skill.root === "package") {
|
|
298
|
+
return skill.root === "package" && skill.packageName === params.pkg;
|
|
299
|
+
}
|
|
300
|
+
return true;
|
|
301
|
+
});
|
|
302
|
+
if (row === undefined)
|
|
303
|
+
return undefined;
|
|
304
|
+
const workspace = deps.workspaceRoot(user);
|
|
305
|
+
const curation = deps.curationOf(user);
|
|
306
|
+
let history;
|
|
307
|
+
let missingReason;
|
|
308
|
+
let historyPath;
|
|
309
|
+
if (row.root === "workspace") {
|
|
310
|
+
history = deps.workspaceHistoryFor(user);
|
|
311
|
+
missingReason =
|
|
312
|
+
workspace === undefined
|
|
313
|
+
? "no workspace is configured for this user"
|
|
314
|
+
: "workspace history is unavailable";
|
|
315
|
+
historyPath = `${row.name}/`;
|
|
316
|
+
}
|
|
317
|
+
else if (row.root === "learned") {
|
|
318
|
+
history = curation?.history;
|
|
319
|
+
missingReason = "learned history is unavailable";
|
|
320
|
+
// An archived skill's history spans BOTH homes (the archive
|
|
321
|
+
// move's commit names curation/archive/<name>; everything
|
|
322
|
+
// earlier names skills/<name>) — the log runs pathless and
|
|
323
|
+
// filters to commits that touched either side.
|
|
324
|
+
historyPath = `skills/${row.name}`;
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
history = undefined;
|
|
328
|
+
missingReason =
|
|
329
|
+
row.root === "package"
|
|
330
|
+
? "package skills ship with their extension — no local history"
|
|
331
|
+
: "the config dir has no history";
|
|
332
|
+
historyPath = `${row.name}/`;
|
|
333
|
+
}
|
|
334
|
+
const availability = await availabilityOf(history, missingReason);
|
|
335
|
+
return {
|
|
336
|
+
params,
|
|
337
|
+
skill: row,
|
|
338
|
+
history,
|
|
339
|
+
historyAvailable: availability.available,
|
|
340
|
+
historyReason: availability.reason,
|
|
341
|
+
historyPaths: row.root === "learned"
|
|
342
|
+
? [`skills/${row.name}`, `curation/archive/${row.name}`, "curation/state.json"]
|
|
343
|
+
: [historyPath],
|
|
344
|
+
deleted: false,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
/** The caller's overlay key for one non-learned selection. */
|
|
348
|
+
function overlayKeyOf(user, row) {
|
|
349
|
+
const workspace = deps.workspaceRoot(user);
|
|
350
|
+
return {
|
|
351
|
+
root: row.root,
|
|
352
|
+
skill: row.name,
|
|
353
|
+
...(row.root === "workspace" && workspace !== undefined ? { workspace } : {}),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/** The main data branch for a resolved selection. */
|
|
357
|
+
async function mainDataOf(user, selection) {
|
|
358
|
+
const row = selection.skill;
|
|
359
|
+
let text = "";
|
|
360
|
+
let description = row.description;
|
|
361
|
+
let body = "";
|
|
362
|
+
if (!selection.deleted && row.error === undefined) {
|
|
363
|
+
try {
|
|
364
|
+
text = await readSkillFile(row.rootDir, row.name, "SKILL.md");
|
|
365
|
+
const parsed = parseSkillFile(text);
|
|
366
|
+
description = parsed?.description ?? row.description;
|
|
367
|
+
body = parsed?.body ?? "";
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
body = `(unreadable: ${error instanceof Error ? error.message : String(error)})`;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const supportFiles = await listSupportFiles(row.rootDir, row.name).catch(() => []);
|
|
374
|
+
let overlay = null;
|
|
375
|
+
if (row.root !== "learned") {
|
|
376
|
+
const curation = deps.curationOf(user);
|
|
377
|
+
const stored = curation === undefined ? undefined : await curation.overlays.get(overlayKeyOf(user, row)).catch(() => undefined);
|
|
378
|
+
overlay = stored === undefined || stored.orphanedSince !== undefined ? null : stored.text;
|
|
379
|
+
}
|
|
380
|
+
let history = {
|
|
381
|
+
available: selection.historyAvailable,
|
|
382
|
+
reason: selection.historyReason,
|
|
383
|
+
items: [],
|
|
384
|
+
hasMore: false,
|
|
385
|
+
nextCursor: null,
|
|
386
|
+
};
|
|
387
|
+
let commit = null;
|
|
388
|
+
if (selection.historyAvailable && selection.history !== undefined) {
|
|
389
|
+
// Multi-path selections (an archived learned skill) page
|
|
390
|
+
// pathless and filter: git's own -- <path> can only take ONE
|
|
391
|
+
// pathspec through history.log, and the union matters more
|
|
392
|
+
// than the fetch margin (the log is per-repo, bounded).
|
|
393
|
+
const pathless = selection.historyPaths.length > 1;
|
|
394
|
+
const raw = await selection.history.log({
|
|
395
|
+
...(pathless ? {} : { path: selection.historyPaths[0] }),
|
|
396
|
+
limit: pathless
|
|
397
|
+
? (SKILLS_HISTORY_PAGE_SIZE + 1) * 4
|
|
398
|
+
: SKILLS_HISTORY_PAGE_SIZE + 1,
|
|
399
|
+
...(selection.params.cursor === undefined
|
|
400
|
+
? {}
|
|
401
|
+
: { before: selection.params.cursor }),
|
|
402
|
+
});
|
|
403
|
+
const commits = pathless
|
|
404
|
+
? raw.filter((one) => one.paths.some((path) => selection.historyPaths.some((root) => path === root || path.startsWith(`${root}/`))))
|
|
405
|
+
: raw;
|
|
406
|
+
const page = historyPage(commits);
|
|
407
|
+
history = {
|
|
408
|
+
available: true,
|
|
409
|
+
reason: null,
|
|
410
|
+
items: page.items,
|
|
411
|
+
hasMore: page.hasMore,
|
|
412
|
+
nextCursor: page.nextCursor,
|
|
413
|
+
};
|
|
414
|
+
if (selection.params.commit !== undefined) {
|
|
415
|
+
const files = await selection.history.changes(selection.params.commit, selection.historyPaths[0]);
|
|
416
|
+
// Review I6: cap the TOTAL inlined bytes across the commit's
|
|
417
|
+
// files (changes() caps each side per file at 64 KiB, but a
|
|
418
|
+
// many-file commit could still blow the 4 MiB view cap and 500
|
|
419
|
+
// the tab) — the overflow collapses into one elision marker.
|
|
420
|
+
commit = { sha: selection.params.commit, files: capCommitFiles(files.map((file) => ({ ...file }))) };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
name: row.name,
|
|
425
|
+
root: row.root,
|
|
426
|
+
...(row.packageName === undefined ? {} : { pkg: row.packageName }),
|
|
427
|
+
rootDir: row.dir,
|
|
428
|
+
description,
|
|
429
|
+
body,
|
|
430
|
+
supportFiles: [...supportFiles],
|
|
431
|
+
overlay,
|
|
432
|
+
history,
|
|
433
|
+
commit,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
/** The form's pre-filled values (a new skill starts empty; an edit reads the current text). */
|
|
437
|
+
async function formDataOf(params, selection) {
|
|
438
|
+
if (params.skill === undefined || selection === undefined) {
|
|
439
|
+
return { name: "", description: "", body: "", where: "workspace" };
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
const text = await readSkillFile(selection.skill.rootDir, selection.skill.name, "SKILL.md");
|
|
443
|
+
const parsed = parseSkillFile(text);
|
|
444
|
+
return {
|
|
445
|
+
name: selection.skill.name,
|
|
446
|
+
description: parsed?.description ?? selection.skill.description,
|
|
447
|
+
body: parsed?.body ?? "",
|
|
448
|
+
where: "workspace",
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
return {
|
|
453
|
+
name: selection.skill.name,
|
|
454
|
+
description: selection.skill.description,
|
|
455
|
+
body: "",
|
|
456
|
+
where: "workspace",
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/** One render's full data document (the tree binds it). */
|
|
461
|
+
async function readData(user, params) {
|
|
462
|
+
const skills = await listFor(user);
|
|
463
|
+
const rows = await rowsFor(user);
|
|
464
|
+
let selection = await resolveSelection(user, params, skills);
|
|
465
|
+
// A Deleted-group row's selection: no live dir, but the repo log
|
|
466
|
+
// still holds the skill — the pane renders its history (and the
|
|
467
|
+
// restore affordance), never a content read.
|
|
468
|
+
if (selection === undefined && params.skill !== undefined) {
|
|
469
|
+
const deletedRow = rows.find((row) => typeof row === "object" &&
|
|
470
|
+
row !== null &&
|
|
471
|
+
row.group === "deleted" &&
|
|
472
|
+
row.name === params.skill &&
|
|
473
|
+
(params.root === undefined ||
|
|
474
|
+
row.id ===
|
|
475
|
+
rowIdOf(params.root, params.skill, params.pkg)));
|
|
476
|
+
if (deletedRow !== undefined) {
|
|
477
|
+
const root = params.root ?? "workspace";
|
|
478
|
+
const history = root === "learned"
|
|
479
|
+
? deps.curationOf(user)?.history
|
|
480
|
+
: deps.workspaceHistoryFor(user);
|
|
481
|
+
const missingReason = root === "learned"
|
|
482
|
+
? "learned history is unavailable"
|
|
483
|
+
: "workspace history is unavailable";
|
|
484
|
+
const availability = await availabilityOf(history, missingReason);
|
|
485
|
+
const synthetic = {
|
|
486
|
+
name: params.skill,
|
|
487
|
+
description: "",
|
|
488
|
+
root,
|
|
489
|
+
rootDir: "",
|
|
490
|
+
dir: "",
|
|
491
|
+
hidden: false,
|
|
492
|
+
};
|
|
493
|
+
selection = {
|
|
494
|
+
params,
|
|
495
|
+
skill: synthetic,
|
|
496
|
+
history,
|
|
497
|
+
historyAvailable: availability.available,
|
|
498
|
+
historyReason: availability.reason,
|
|
499
|
+
historyPaths: root === "learned"
|
|
500
|
+
? [`skills/${params.skill}`, `curation/archive/${params.skill}`, "curation/state.json"]
|
|
501
|
+
: [`${params.skill}/`],
|
|
502
|
+
deleted: true,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const main = selection === undefined ? undefined : await mainDataOf(user, selection);
|
|
507
|
+
const form = await formDataOf(params, selection);
|
|
508
|
+
const writable = selection === undefined || selection.skill.root === "learned" || selection.skill.root === "workspace";
|
|
509
|
+
// The actions callout shows for overlay-capable roots too (their
|
|
510
|
+
// buttons are overlay-only) — `writable` is the OWN-roots gate.
|
|
511
|
+
const actionable = writable || selection?.skill.root === "user dir" || selection?.skill.root === "package";
|
|
512
|
+
const data = {
|
|
513
|
+
skills: rows,
|
|
514
|
+
main: main ?? null,
|
|
515
|
+
form,
|
|
516
|
+
};
|
|
517
|
+
// SAFETY: the data document is a JSON mirror (every field a
|
|
518
|
+
// literal); the interfaces lack the index signature JsonObject
|
|
519
|
+
// requires, so the cast only restores that erased identity.
|
|
520
|
+
return { data: data, selection, main, form, writable: actionable };
|
|
521
|
+
}
|
|
522
|
+
/** The caller's own-root write gate for actions (read-only roots refuse). */
|
|
523
|
+
const selectedParams = (ctx) => skillsParamsOf(ctx.params);
|
|
524
|
+
/** One action's SELECTION: the params' row, or the archived/deleted
|
|
525
|
+
* synthetic row the params name (restoreVersion/restoreDeleted reach
|
|
526
|
+
* skills whose live dir is gone — listAllRoots never sees them). */
|
|
527
|
+
async function actionSelection(user, root, name) {
|
|
528
|
+
const skills = await listFor(user);
|
|
529
|
+
const live = await resolveSelection(user, { skill: name, root }, skills);
|
|
530
|
+
if (live !== undefined)
|
|
531
|
+
return live;
|
|
532
|
+
// The archived/deleted synthetic (the Deleted-group selection):
|
|
533
|
+
// learned → the curation repo; anything else → the workspace repo.
|
|
534
|
+
if (root !== "learned" && root !== "workspace")
|
|
535
|
+
return undefined;
|
|
536
|
+
const history = root === "learned" ? deps.curationOf(user)?.history : deps.workspaceHistoryFor(user);
|
|
537
|
+
const missingReason = root === "learned" ? "learned history is unavailable" : "workspace history is unavailable";
|
|
538
|
+
const availability = await availabilityOf(history, missingReason);
|
|
539
|
+
if (!availability.available)
|
|
540
|
+
return undefined;
|
|
541
|
+
const synthetic = {
|
|
542
|
+
name,
|
|
543
|
+
description: "",
|
|
544
|
+
root,
|
|
545
|
+
rootDir: "",
|
|
546
|
+
dir: "",
|
|
547
|
+
hidden: false,
|
|
548
|
+
};
|
|
549
|
+
return {
|
|
550
|
+
params: { skill: name, root },
|
|
551
|
+
skill: synthetic,
|
|
552
|
+
history,
|
|
553
|
+
historyAvailable: true,
|
|
554
|
+
historyReason: null,
|
|
555
|
+
historyPaths: root === "learned"
|
|
556
|
+
? [`skills/${name}`, `curation/archive/${name}`, "curation/state.json"]
|
|
557
|
+
: [`${name}/`],
|
|
558
|
+
deleted: true,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
/** The row an action targets: payload override, else the instance params. */
|
|
562
|
+
async function targetRow(ctx, payload) {
|
|
563
|
+
const user = userOf(ctx);
|
|
564
|
+
if (user === undefined)
|
|
565
|
+
return { error: { ok: false, error: { message: "forbidden" } } };
|
|
566
|
+
const skills = await listFor(user);
|
|
567
|
+
const root = (fieldOf(payload, "root") ?? selectedParams(ctx).root);
|
|
568
|
+
const name = fieldOf(payload, "name") ?? selectedParams(ctx).skill;
|
|
569
|
+
const pkg = fieldOf(payload, "pkg") ?? selectedParams(ctx).pkg;
|
|
570
|
+
if (name === undefined || root === undefined) {
|
|
571
|
+
return { error: { ok: false, error: { message: "the action needs a selected skill (params.skill + params.root)" } } };
|
|
572
|
+
}
|
|
573
|
+
const selection = pkg === undefined
|
|
574
|
+
? await actionSelection(user, root, name)
|
|
575
|
+
: await resolveSelection(user, { ...selectedParams(ctx), skill: name, root, pkg }, skills);
|
|
576
|
+
if (selection === undefined) {
|
|
577
|
+
return { error: { ok: false, error: { message: `skill not found: ${name}` } } };
|
|
578
|
+
}
|
|
579
|
+
return { user, skills, selection };
|
|
580
|
+
}
|
|
581
|
+
/** The workspace write: content checks + refuse-if-exists (create) + history write. */
|
|
582
|
+
async function workspaceWrite(ctx, payload, opts) {
|
|
583
|
+
const user = userOf(ctx);
|
|
584
|
+
if (user === undefined)
|
|
585
|
+
return { ok: false, error: { message: "forbidden" } };
|
|
586
|
+
const history = deps.workspaceHistoryFor(user);
|
|
587
|
+
const workspace = deps.workspaceRoot(user);
|
|
588
|
+
if (history === undefined || workspace === undefined) {
|
|
589
|
+
return { ok: false, error: { message: "workspace history is unavailable" } };
|
|
590
|
+
}
|
|
591
|
+
const name = fieldOf(payload, "name") ?? selectedParams(ctx).skill;
|
|
592
|
+
const description = fieldOf(payload, "description");
|
|
593
|
+
const body = fieldOf(payload, "body");
|
|
594
|
+
if (name === undefined || !validSkillName(name)) {
|
|
595
|
+
return { ok: false, error: { message: "a valid skill name is required (lowercase letters, digits, dashes)", field: "name" } };
|
|
596
|
+
}
|
|
597
|
+
if (description === undefined || description.trim() === "") {
|
|
598
|
+
return { ok: false, error: { message: "a description is required", field: "description" } };
|
|
599
|
+
}
|
|
600
|
+
const text = skillFileText(name, description.trim(), body ?? "");
|
|
601
|
+
const scanned = scanContent(text);
|
|
602
|
+
if (!scanned.ok) {
|
|
603
|
+
return { ok: false, error: { message: `content scan refused line ${scanned.line}: ${scanned.rule}`, field: "body" } };
|
|
604
|
+
}
|
|
605
|
+
// The work tree (<workspace>/skills) must exist BEFORE the
|
|
606
|
+
// history write: prepare()'s access check fails a missing tree,
|
|
607
|
+
// and the mutation would land with no commit (the e2e race: a
|
|
608
|
+
// fresh workspace has no skills dir until the first skill).
|
|
609
|
+
const skillsRoot = join(workspace, "skills");
|
|
610
|
+
await mkdir(skillsRoot, { recursive: true });
|
|
611
|
+
const skills = await listFor(user);
|
|
612
|
+
const existing = skills.find((skill) => skill.name === name && skill.root === "workspace");
|
|
613
|
+
if (opts.mode === "create" && existing !== undefined) {
|
|
614
|
+
return { ok: false, error: { message: `skill already exists: ${name}`, field: "name" } };
|
|
615
|
+
}
|
|
616
|
+
if (opts.mode === "save" && existing === undefined) {
|
|
617
|
+
return { ok: false, error: { message: `skill not found: ${name}` } };
|
|
618
|
+
}
|
|
619
|
+
try {
|
|
620
|
+
const sha = await history.write({
|
|
621
|
+
actor: { kind: "user", user },
|
|
622
|
+
summary: opts.mode === "create" ? `create ${name}` : `edit ${name}`,
|
|
623
|
+
action: opts.mode === "create" ? "create" : "patch",
|
|
624
|
+
skill: name,
|
|
625
|
+
}, async () => {
|
|
626
|
+
const dir = join(skillsRoot, name);
|
|
627
|
+
await mkdir(dir, { recursive: true });
|
|
628
|
+
await writeFile(join(dir, "SKILL.md"), text);
|
|
629
|
+
}, { kind: "outside" });
|
|
630
|
+
void sha;
|
|
631
|
+
deps.bump();
|
|
632
|
+
return {
|
|
633
|
+
ok: true,
|
|
634
|
+
toast: `${opts.mode === "create" ? "Created" : "Saved"} ${name}`,
|
|
635
|
+
navigate: { type: "navigate", params: { skill: name, root: "workspace" } },
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
return failure(error);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return {
|
|
643
|
+
id: "skills",
|
|
644
|
+
title: "Skills",
|
|
645
|
+
icon: "book",
|
|
646
|
+
placement: "view",
|
|
647
|
+
instanceKey: skillsInstanceKey,
|
|
648
|
+
render: async (ctx) => {
|
|
649
|
+
const user = userOf(ctx);
|
|
650
|
+
if (user === undefined) {
|
|
651
|
+
return { tree: skillsNoneTree(), data: {}, access: "none" };
|
|
652
|
+
}
|
|
653
|
+
await catchUpBoth(user);
|
|
654
|
+
const params = skillsParamsOf(ctx.params);
|
|
655
|
+
const { data, selection, main, form, writable } = await readData(user, params);
|
|
656
|
+
return {
|
|
657
|
+
tree: buildSkillsTree(params, main, form, {
|
|
658
|
+
writable,
|
|
659
|
+
historyAvailable: selection?.historyAvailable ?? false,
|
|
660
|
+
selectedExists: selection !== undefined,
|
|
661
|
+
...(selection?.deleted === true ? { deleted: true } : {}),
|
|
662
|
+
}),
|
|
663
|
+
data,
|
|
664
|
+
access: "write",
|
|
665
|
+
};
|
|
666
|
+
},
|
|
667
|
+
data: async (ctx) => {
|
|
668
|
+
const user = userOf(ctx);
|
|
669
|
+
if (user === undefined)
|
|
670
|
+
return { data: {}, access: "none" };
|
|
671
|
+
// The data path renders too (a refetch IS a tab-open cycle), so
|
|
672
|
+
// the throttle — not the route — is the git-spawn guard.
|
|
673
|
+
await catchUpBoth(user);
|
|
674
|
+
const params = skillsParamsOf(ctx.params);
|
|
675
|
+
const { data } = await readData(user, params);
|
|
676
|
+
return { data, access: "write" };
|
|
677
|
+
},
|
|
678
|
+
actions: {
|
|
679
|
+
// The sidebar row's drill-in: {itemId} = the row id (root:name[:pkg]).
|
|
680
|
+
openSkill: async (ctx, payload) => {
|
|
681
|
+
if (userOf(ctx) === undefined)
|
|
682
|
+
return { ok: false, error: { message: "forbidden" } };
|
|
683
|
+
const parsed = parseRowId(itemIdOf(payload) ?? "");
|
|
684
|
+
if (parsed === undefined) {
|
|
685
|
+
return { ok: false, error: { message: "openSkill expects {itemId}" } };
|
|
686
|
+
}
|
|
687
|
+
return {
|
|
688
|
+
ok: true,
|
|
689
|
+
navigate: {
|
|
690
|
+
type: "navigate",
|
|
691
|
+
params: {
|
|
692
|
+
skill: parsed.name,
|
|
693
|
+
root: parsed.root,
|
|
694
|
+
...(parsed.pkg === undefined ? {} : { pkg: parsed.pkg }),
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
};
|
|
698
|
+
},
|
|
699
|
+
// A history row's drill-in: {itemId} = the commit sha.
|
|
700
|
+
openCommit: async (ctx, payload) => {
|
|
701
|
+
if (userOf(ctx) === undefined)
|
|
702
|
+
return { ok: false, error: { message: "forbidden" } };
|
|
703
|
+
const sha = itemIdOf(payload);
|
|
704
|
+
if (sha === undefined) {
|
|
705
|
+
return { ok: false, error: { message: "openCommit expects {itemId}" } };
|
|
706
|
+
}
|
|
707
|
+
const params = selectedParams(ctx);
|
|
708
|
+
return {
|
|
709
|
+
ok: true,
|
|
710
|
+
navigate: {
|
|
711
|
+
type: "navigate",
|
|
712
|
+
params: {
|
|
713
|
+
...(params.skill === undefined ? {} : { skill: params.skill }),
|
|
714
|
+
...(params.root === undefined ? {} : { root: params.root }),
|
|
715
|
+
...(params.pkg === undefined ? {} : { pkg: params.pkg }),
|
|
716
|
+
commit: sha,
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
},
|
|
721
|
+
create: async (ctx, payload) => {
|
|
722
|
+
const user = userOf(ctx);
|
|
723
|
+
if (user === undefined)
|
|
724
|
+
return { ok: false, error: { message: "forbidden" } };
|
|
725
|
+
const where = fieldOf(payload, "where") ?? "workspace";
|
|
726
|
+
const name = fieldOf(payload, "name");
|
|
727
|
+
const description = fieldOf(payload, "description");
|
|
728
|
+
const body = fieldOf(payload, "body");
|
|
729
|
+
if (name === undefined || !validSkillName(name)) {
|
|
730
|
+
return { ok: false, error: { message: "a valid skill name is required (lowercase letters, digits, dashes)", field: "name" } };
|
|
731
|
+
}
|
|
732
|
+
if (description === undefined || description.trim() === "") {
|
|
733
|
+
return { ok: false, error: { message: "a description is required", field: "description" } };
|
|
734
|
+
}
|
|
735
|
+
if (where === "learned") {
|
|
736
|
+
const curation = deps.curationOf(user);
|
|
737
|
+
if (curation === undefined) {
|
|
738
|
+
return { ok: false, error: { message: "learned skills are unavailable" } };
|
|
739
|
+
}
|
|
740
|
+
try {
|
|
741
|
+
await curation.store.apply(name, { kind: "create", content: skillFileText(name, description.trim(), body ?? "") }, { actor: "user" });
|
|
742
|
+
}
|
|
743
|
+
catch (error) {
|
|
744
|
+
return failure(error);
|
|
745
|
+
}
|
|
746
|
+
deps.bump();
|
|
747
|
+
return {
|
|
748
|
+
ok: true,
|
|
749
|
+
toast: `Created ${name}`,
|
|
750
|
+
navigate: { type: "navigate", params: { skill: name, root: "learned" } },
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
return workspaceWrite(ctx, payload, { mode: "create" });
|
|
754
|
+
},
|
|
755
|
+
save: async (ctx, payload) => {
|
|
756
|
+
const user = userOf(ctx);
|
|
757
|
+
if (user === undefined)
|
|
758
|
+
return { ok: false, error: { message: "forbidden" } };
|
|
759
|
+
// The form's name field is DISABLED on an existing skill
|
|
760
|
+
// (names are read-only after creation) — a disabled field never
|
|
761
|
+
// rides the submit envelope, so the name comes from the params.
|
|
762
|
+
const name = selectedParams(ctx).skill ?? fieldOf(payload, "name");
|
|
763
|
+
const description = fieldOf(payload, "description");
|
|
764
|
+
const body = fieldOf(payload, "body");
|
|
765
|
+
if (name === undefined) {
|
|
766
|
+
return { ok: false, error: { message: "save needs a selected skill (params.skill)" } };
|
|
767
|
+
}
|
|
768
|
+
if (description === undefined || description.trim() === "") {
|
|
769
|
+
return { ok: false, error: { message: "a description is required", field: "description" } };
|
|
770
|
+
}
|
|
771
|
+
const root = (fieldOf(payload, "root") ?? selectedParams(ctx).root);
|
|
772
|
+
if (root === "learned") {
|
|
773
|
+
const curation = deps.curationOf(user);
|
|
774
|
+
if (curation === undefined) {
|
|
775
|
+
return { ok: false, error: { message: "learned skills are unavailable" } };
|
|
776
|
+
}
|
|
777
|
+
try {
|
|
778
|
+
const current = await curation.store.view(name);
|
|
779
|
+
const next = skillFileText(name, description.trim(), body ?? "");
|
|
780
|
+
await curation.store.apply(name, { kind: "patch", old: current, new: next }, { actor: "user" });
|
|
781
|
+
}
|
|
782
|
+
catch (error) {
|
|
783
|
+
return failure(error);
|
|
784
|
+
}
|
|
785
|
+
deps.bump();
|
|
786
|
+
return {
|
|
787
|
+
ok: true,
|
|
788
|
+
toast: `Saved ${name}`,
|
|
789
|
+
navigate: { type: "navigate", params: { skill: name, root: "learned" } },
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
return workspaceWrite(ctx, { ...(isObject(payload) ? payload : {}), name }, { mode: "save" });
|
|
793
|
+
},
|
|
794
|
+
delete: async (ctx, payload) => {
|
|
795
|
+
const targeted = await targetRow(ctx, payload);
|
|
796
|
+
if ("error" in targeted)
|
|
797
|
+
return targeted.error;
|
|
798
|
+
const { user, selection } = targeted;
|
|
799
|
+
const name = selection.skill.name;
|
|
800
|
+
if (selection.skill.root === "learned") {
|
|
801
|
+
const curation = deps.curationOf(user);
|
|
802
|
+
if (curation === undefined)
|
|
803
|
+
return { ok: false, error: { message: "learned skills are unavailable" } };
|
|
804
|
+
try {
|
|
805
|
+
await curation.store.apply(name, { kind: "archive" }, { actor: "user" });
|
|
806
|
+
}
|
|
807
|
+
catch (error) {
|
|
808
|
+
return failure(error);
|
|
809
|
+
}
|
|
810
|
+
deps.bump();
|
|
811
|
+
return { ok: true, toast: `Deleted ${name} (archived)`, navigate: { type: "navigate", params: {} } };
|
|
812
|
+
}
|
|
813
|
+
if (selection.skill.root === "workspace") {
|
|
814
|
+
const workspace = deps.workspaceRoot(user);
|
|
815
|
+
if (workspace === undefined) {
|
|
816
|
+
return { ok: false, error: { message: "no workspace is configured for this user" } };
|
|
817
|
+
}
|
|
818
|
+
try {
|
|
819
|
+
const history = deps.workspaceHistoryFor(user);
|
|
820
|
+
// The mutation runs with or without the repo (GitHistory's
|
|
821
|
+
// fail-soft contract): no history → the delete still lands.
|
|
822
|
+
if (history === undefined) {
|
|
823
|
+
await rm(join(workspace, "skills", name), { recursive: true, force: true });
|
|
824
|
+
}
|
|
825
|
+
else {
|
|
826
|
+
await history.write({
|
|
827
|
+
actor: { kind: "user", user },
|
|
828
|
+
summary: `delete ${name}`,
|
|
829
|
+
action: "remove_file",
|
|
830
|
+
skill: name,
|
|
831
|
+
}, async () => {
|
|
832
|
+
await rm(join(workspace, "skills", name), { recursive: true, force: true });
|
|
833
|
+
}, { kind: "outside" });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
return failure(error);
|
|
838
|
+
}
|
|
839
|
+
deps.bump();
|
|
840
|
+
return { ok: true, toast: `Deleted ${name}`, navigate: { type: "navigate", params: {} } };
|
|
841
|
+
}
|
|
842
|
+
return { ok: false, error: { message: `${selection.skill.root} skills are read-only here` } };
|
|
843
|
+
},
|
|
844
|
+
restoreVersion: async (ctx, payload) => {
|
|
845
|
+
const targeted = await targetRow(ctx, payload);
|
|
846
|
+
if ("error" in targeted)
|
|
847
|
+
return targeted.error;
|
|
848
|
+
const { user, selection } = targeted;
|
|
849
|
+
const sha = fieldOf(payload, "sha") ?? selectedParams(ctx).commit;
|
|
850
|
+
if (sha === undefined) {
|
|
851
|
+
return { ok: false, error: { message: "restoreVersion needs a commit (params.commit)" } };
|
|
852
|
+
}
|
|
853
|
+
const name = selection.skill.name;
|
|
854
|
+
if (selection.skill.root === "learned") {
|
|
855
|
+
const curation = deps.curationOf(user);
|
|
856
|
+
if (curation === undefined)
|
|
857
|
+
return { ok: false, error: { message: "learned skills are unavailable" } };
|
|
858
|
+
try {
|
|
859
|
+
await curation.store.restoreVersion(name, sha, { actor: "user" });
|
|
860
|
+
}
|
|
861
|
+
catch (error) {
|
|
862
|
+
return failure(error);
|
|
863
|
+
}
|
|
864
|
+
deps.bump();
|
|
865
|
+
return { ok: true, toast: `Restored ${name} to ${sha.slice(0, 8)}` };
|
|
866
|
+
}
|
|
867
|
+
if (selection.skill.root === "workspace") {
|
|
868
|
+
const history = deps.workspaceHistoryFor(user);
|
|
869
|
+
if (history === undefined) {
|
|
870
|
+
return { ok: false, error: { message: "workspace history is unavailable" } };
|
|
871
|
+
}
|
|
872
|
+
// Whole-branch review I2: the reused-name hole. When the
|
|
873
|
+
// name was deleted and then RE-created, the live skill's
|
|
874
|
+
// history chain includes the old skill's commits (same
|
|
875
|
+
// path), so a restore anchored on a PRE-DELETE commit would
|
|
876
|
+
// silently overwrite the live (reused) skill. Refuse the
|
|
877
|
+
// moment the target predates the newest delete while the
|
|
878
|
+
// live row exists — restoreDeleted's refusal, applied to
|
|
879
|
+
// the reachable path.
|
|
880
|
+
const skills = await listFor(user);
|
|
881
|
+
const live = skills.some((one) => one.name === name && one.root === "workspace");
|
|
882
|
+
if (live) {
|
|
883
|
+
const deletionSha = await newestDeletionSha(history, name);
|
|
884
|
+
if (deletionSha !== undefined) {
|
|
885
|
+
// The name's own history, newest-first: every commit
|
|
886
|
+
// listed AFTER the delete is older than it (the
|
|
887
|
+
// pre-reuse chain — restoring from it would silently
|
|
888
|
+
// overwrite the live, reused skill).
|
|
889
|
+
const pathLog = await history.log({ path: `${name}/`, limit: 10_000 });
|
|
890
|
+
const deleteAt = pathLogIndex(pathLog, deletionSha);
|
|
891
|
+
const targetAt = pathLogIndex(pathLog, sha);
|
|
892
|
+
if (deleteAt !== -1 &&
|
|
893
|
+
(targetAt === -1 || targetAt > deleteAt)) {
|
|
894
|
+
return {
|
|
895
|
+
ok: false,
|
|
896
|
+
error: { message: reusedNameError(name, "workspace") },
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
try {
|
|
902
|
+
await history.restore(sha, [name], {
|
|
903
|
+
actor: { kind: "user", user },
|
|
904
|
+
summary: `restore ${name}@${sha.slice(0, 8)}`,
|
|
905
|
+
action: "restore",
|
|
906
|
+
skill: name,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
catch (error) {
|
|
910
|
+
return failure(error);
|
|
911
|
+
}
|
|
912
|
+
deps.bump();
|
|
913
|
+
return { ok: true, toast: `Restored ${name} to ${sha.slice(0, 8)}` };
|
|
914
|
+
}
|
|
915
|
+
return { ok: false, error: { message: `${selection.skill.root} skills are read-only here` } };
|
|
916
|
+
},
|
|
917
|
+
restoreDeleted: async (ctx, payload) => {
|
|
918
|
+
const user = userOf(ctx);
|
|
919
|
+
if (user === undefined)
|
|
920
|
+
return { ok: false, error: { message: "forbidden" } };
|
|
921
|
+
const name = fieldOf(payload, "name") ?? selectedParams(ctx).skill;
|
|
922
|
+
const sha = fieldOf(payload, "sha") ?? selectedParams(ctx).commit;
|
|
923
|
+
if (name === undefined || sha === undefined) {
|
|
924
|
+
return { ok: false, error: { message: "restoreDeleted needs a name and a commit sha" } };
|
|
925
|
+
}
|
|
926
|
+
const skills = await listFor(user);
|
|
927
|
+
// Review Focus 5: a reused name refuses, naming the root that has it.
|
|
928
|
+
const present = presentRootOf(skills, name);
|
|
929
|
+
if (present !== undefined) {
|
|
930
|
+
return {
|
|
931
|
+
ok: false,
|
|
932
|
+
error: { message: reusedNameError(name, present) },
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
const root = (fieldOf(payload, "root") ?? selectedParams(ctx).root ?? "workspace");
|
|
936
|
+
if (root === "learned") {
|
|
937
|
+
const curation = deps.curationOf(user);
|
|
938
|
+
if (curation === undefined)
|
|
939
|
+
return { ok: false, error: { message: "learned skills are unavailable" } };
|
|
940
|
+
try {
|
|
941
|
+
// The anchor: the row's sha is the newest commit touching
|
|
942
|
+
// the ARCHIVE path — the archive-move commit itself. The
|
|
943
|
+
// live state (files in skills/ + the state.json row) is
|
|
944
|
+
// its PARENT's tree; restoring it un-archives the name.
|
|
945
|
+
const parent = await curation.history.parentOf(sha);
|
|
946
|
+
if (parent === undefined) {
|
|
947
|
+
return { ok: false, error: { message: "the deletion commit has no parent to restore from" } };
|
|
948
|
+
}
|
|
949
|
+
await curation.store.restoreVersion(name, parent, { actor: "user" });
|
|
950
|
+
}
|
|
951
|
+
catch (error) {
|
|
952
|
+
return failure(error);
|
|
953
|
+
}
|
|
954
|
+
deps.bump();
|
|
955
|
+
return { ok: true, toast: `Restored ${name}`, navigate: { type: "navigate", params: { skill: name, root: "learned" } } };
|
|
956
|
+
}
|
|
957
|
+
const history = deps.workspaceHistoryFor(user);
|
|
958
|
+
if (history === undefined) {
|
|
959
|
+
return { ok: false, error: { message: "workspace history is unavailable" } };
|
|
960
|
+
}
|
|
961
|
+
try {
|
|
962
|
+
const parent = await history.parentOf(sha);
|
|
963
|
+
await history.restore(parent, [name], {
|
|
964
|
+
actor: { kind: "user", user },
|
|
965
|
+
summary: `restore ${name} (was deleted)`,
|
|
966
|
+
action: "restore",
|
|
967
|
+
skill: name,
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
catch (error) {
|
|
971
|
+
return failure(error);
|
|
972
|
+
}
|
|
973
|
+
deps.bump();
|
|
974
|
+
return { ok: true, toast: `Restored ${name}`, navigate: { type: "navigate", params: { skill: name, root: "workspace" } } };
|
|
975
|
+
},
|
|
976
|
+
pin: async (ctx, payload) => {
|
|
977
|
+
const targeted = await targetRow(ctx, payload);
|
|
978
|
+
if ("error" in targeted)
|
|
979
|
+
return targeted.error;
|
|
980
|
+
const { user, selection } = targeted;
|
|
981
|
+
if (selection.skill.root !== "learned") {
|
|
982
|
+
return { ok: false, error: { message: "only learned skills pin" } };
|
|
983
|
+
}
|
|
984
|
+
const curation = deps.curationOf(user);
|
|
985
|
+
if (curation === undefined)
|
|
986
|
+
return { ok: false, error: { message: "learned skills are unavailable" } };
|
|
987
|
+
try {
|
|
988
|
+
await curation.store.pin(selection.skill.name, true);
|
|
989
|
+
}
|
|
990
|
+
catch (error) {
|
|
991
|
+
return failure(error);
|
|
992
|
+
}
|
|
993
|
+
deps.bump();
|
|
994
|
+
return { ok: true, toast: `Pinned ${selection.skill.name}` };
|
|
995
|
+
},
|
|
996
|
+
archive: async (ctx, payload) => {
|
|
997
|
+
const targeted = await targetRow(ctx, payload);
|
|
998
|
+
if ("error" in targeted)
|
|
999
|
+
return targeted.error;
|
|
1000
|
+
const { user, selection } = targeted;
|
|
1001
|
+
if (selection.skill.root !== "learned") {
|
|
1002
|
+
return { ok: false, error: { message: "only learned skills archive" } };
|
|
1003
|
+
}
|
|
1004
|
+
const curation = deps.curationOf(user);
|
|
1005
|
+
if (curation === undefined)
|
|
1006
|
+
return { ok: false, error: { message: "learned skills are unavailable" } };
|
|
1007
|
+
try {
|
|
1008
|
+
await curation.store.apply(selection.skill.name, { kind: "archive" }, { actor: "user" });
|
|
1009
|
+
}
|
|
1010
|
+
catch (error) {
|
|
1011
|
+
return failure(error);
|
|
1012
|
+
}
|
|
1013
|
+
deps.bump();
|
|
1014
|
+
return { ok: true, toast: `Archived ${selection.skill.name}` };
|
|
1015
|
+
},
|
|
1016
|
+
overlayPut: async (ctx, payload) => {
|
|
1017
|
+
const targeted = await targetRow(ctx, payload);
|
|
1018
|
+
if ("error" in targeted)
|
|
1019
|
+
return targeted.error;
|
|
1020
|
+
const { user, selection } = targeted;
|
|
1021
|
+
if (selection.skill.root === "learned") {
|
|
1022
|
+
return { ok: false, error: { message: "an overlay never applies to a learned skill" } };
|
|
1023
|
+
}
|
|
1024
|
+
const curation = deps.curationOf(user);
|
|
1025
|
+
if (curation === undefined)
|
|
1026
|
+
return { ok: false, error: { message: "overlays are unavailable" } };
|
|
1027
|
+
const text = fieldOf(payload, "text");
|
|
1028
|
+
if (text === undefined || text.trim() === "") {
|
|
1029
|
+
return { ok: false, error: { message: "overlay text is required", field: "text" } };
|
|
1030
|
+
}
|
|
1031
|
+
const workspace = deps.workspaceRoot(user);
|
|
1032
|
+
const key = {
|
|
1033
|
+
root: selection.skill.root,
|
|
1034
|
+
skill: selection.skill.name,
|
|
1035
|
+
...(selection.skill.root === "workspace" && workspace !== undefined
|
|
1036
|
+
? { workspace }
|
|
1037
|
+
: {}),
|
|
1038
|
+
};
|
|
1039
|
+
try {
|
|
1040
|
+
const upstream = await resolveUpstream(key, undefined, BACKGROUND, {
|
|
1041
|
+
userDir: deps.userDir,
|
|
1042
|
+
packageRoots: deps.packageRoots,
|
|
1043
|
+
});
|
|
1044
|
+
if (upstream === undefined) {
|
|
1045
|
+
return { ok: false, error: { message: `the upstream skill is absent: ${selection.skill.name}` } };
|
|
1046
|
+
}
|
|
1047
|
+
await curation.overlays.put(key, text, upstream.hash, { actor: "user" });
|
|
1048
|
+
}
|
|
1049
|
+
catch (error) {
|
|
1050
|
+
return failure(error);
|
|
1051
|
+
}
|
|
1052
|
+
deps.bump();
|
|
1053
|
+
return { ok: true, toast: `Overlay saved for ${selection.skill.name}` };
|
|
1054
|
+
},
|
|
1055
|
+
overlayRemove: async (ctx, payload) => {
|
|
1056
|
+
const targeted = await targetRow(ctx, payload);
|
|
1057
|
+
if ("error" in targeted)
|
|
1058
|
+
return targeted.error;
|
|
1059
|
+
const { user, selection } = targeted;
|
|
1060
|
+
if (selection.skill.root === "learned") {
|
|
1061
|
+
return { ok: false, error: { message: "an overlay never applies to a learned skill" } };
|
|
1062
|
+
}
|
|
1063
|
+
const curation = deps.curationOf(user);
|
|
1064
|
+
if (curation === undefined)
|
|
1065
|
+
return { ok: false, error: { message: "overlays are unavailable" } };
|
|
1066
|
+
const workspace = deps.workspaceRoot(user);
|
|
1067
|
+
const key = {
|
|
1068
|
+
root: selection.skill.root,
|
|
1069
|
+
skill: selection.skill.name,
|
|
1070
|
+
...(selection.skill.root === "workspace" && workspace !== undefined
|
|
1071
|
+
? { workspace }
|
|
1072
|
+
: {}),
|
|
1073
|
+
};
|
|
1074
|
+
try {
|
|
1075
|
+
await curation.overlays.remove(key, { actor: "user" });
|
|
1076
|
+
}
|
|
1077
|
+
catch (error) {
|
|
1078
|
+
return failure(error);
|
|
1079
|
+
}
|
|
1080
|
+
deps.bump();
|
|
1081
|
+
return { ok: true, toast: `Overlay removed for ${selection.skill.name}` };
|
|
1082
|
+
},
|
|
1083
|
+
},
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
/** The chord background context for overlay resolution (no conversation env). */
|
|
1087
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
1088
|
+
const BACKGROUND = BACKGROUND_CONTEXT;
|
|
1089
|
+
// Re-exported so the server's attach and the tests share the row-id shape.
|
|
1090
|
+
export { rowIdOf };
|