sfora-cli 0.14.0 → 0.16.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/README.md +128 -0
- package/dist/SforaFs.js +68 -0
- package/dist/api-client.d.ts +23 -0
- package/dist/api-client.js +29 -3
- package/dist/block-commands.d.ts +1 -0
- package/dist/block-commands.js +1 -0
- package/dist/cli-args.d.ts +4 -0
- package/dist/cli-args.js +8 -0
- package/dist/cli.js +39 -33
- package/dist/config.d.ts +2 -0
- package/dist/config.js +15 -6
- package/dist/format/cardMarkdown.d.ts +1 -0
- package/dist/format/cardMarkdown.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/local/workspace.d.ts +0 -16
- package/dist/local/workspace.js +34 -28
- package/dist/local-core/files.d.ts +23 -0
- package/dist/local-core/files.js +110 -0
- package/dist/local-core/index.d.ts +16 -0
- package/dist/local-core/index.js +15 -0
- package/dist/local-core/skill-adapters.d.ts +21 -0
- package/dist/local-core/skill-adapters.js +19 -0
- package/dist/local-core/skill-discovery.d.ts +22 -0
- package/dist/local-core/skill-discovery.js +79 -0
- package/dist/local-core/skill-domain.d.ts +74 -0
- package/dist/local-core/skill-executor.d.ts +23 -0
- package/dist/local-core/skill-executor.js +51 -0
- package/dist/local-core/skill-index.d.ts +54 -0
- package/dist/local-core/skill-index.js +115 -0
- package/dist/local-core/skill-local-executor.d.ts +18 -0
- package/dist/local-core/skill-local-executor.js +249 -0
- package/dist/local-core/skill-operations.d.ts +61 -0
- package/dist/local-core/skill-operations.js +268 -0
- package/dist/local-core/skill-review.d.ts +46 -0
- package/dist/local-core/skill-review.js +132 -0
- package/dist/local-core/skill-service.d.ts +96 -0
- package/dist/local-core/skill-service.js +157 -0
- package/dist/local-core/skill-store.d.ts +34 -0
- package/dist/local-core/skill-store.js +187 -0
- package/dist/local-core/skill-sync.d.ts +132 -0
- package/dist/local-core/skill-sync.js +111 -0
- package/dist/local-core/skills.d.ts +55 -0
- package/dist/local-core/skills.js +251 -0
- package/dist/skills-client.d.ts +32 -0
- package/dist/skills-client.js +88 -0
- package/dist/skills-command.d.ts +4 -0
- package/dist/skills-command.js +223 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/package.json +21 -3
- package/dist/config.test.js +0 -94
- /package/dist/{config.test.d.ts → local-core/skill-domain.js} +0 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { mkdir, lstat, open } from 'node:fs/promises';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import { readSkillBundle } from './skills.js';
|
|
6
|
+
import { assertSkillBindingSnapshot, planSkillTransfer } from './skill-sync.js';
|
|
7
|
+
export const operationDirection = (plan) => plan.kind === 'skill-install-preview' ? 'install' : plan.direction;
|
|
8
|
+
export const operationRemote = (plan) => plan.kind === 'skill-install-preview' ? plan.source : plan.binding.remote;
|
|
9
|
+
export const operationDestination = (plan) => plan.kind === 'skill-install-preview' ? plan.destination : plan.location.path;
|
|
10
|
+
/** Separate versioned journal: intent commits before network I/O. A dead worker never retains a SQLite lock. */
|
|
11
|
+
export class SkillOperationJournal {
|
|
12
|
+
path;
|
|
13
|
+
constructor(path) {
|
|
14
|
+
this.path = path;
|
|
15
|
+
}
|
|
16
|
+
async access(change) {
|
|
17
|
+
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
18
|
+
const handle = await open(this.path, 'ax', 0o600).catch(e => { if (e.code === 'EEXIST')
|
|
19
|
+
return null; throw e; });
|
|
20
|
+
await handle?.close();
|
|
21
|
+
const info = await lstat(this.path);
|
|
22
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 64 * 1024 * 1024)
|
|
23
|
+
throw new Error('Invalid skill operation journal file.');
|
|
24
|
+
const db = new DatabaseSync(this.path);
|
|
25
|
+
try {
|
|
26
|
+
db.exec('PRAGMA busy_timeout=5000; PRAGMA synchronous=FULL; BEGIN IMMEDIATE;');
|
|
27
|
+
const version = Number(db.prepare('PRAGMA user_version').get().user_version);
|
|
28
|
+
if (version === 0) {
|
|
29
|
+
if (db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().length)
|
|
30
|
+
throw new Error('Unrecognized operation journal.');
|
|
31
|
+
db.exec('CREATE TABLE operations(id TEXT PRIMARY KEY,data TEXT NOT NULL); PRAGMA user_version=1;');
|
|
32
|
+
}
|
|
33
|
+
else if (version !== 1)
|
|
34
|
+
throw new Error('Unsupported operation journal version.');
|
|
35
|
+
const result = change(db);
|
|
36
|
+
db.exec('COMMIT');
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
try {
|
|
41
|
+
db.exec('ROLLBACK');
|
|
42
|
+
}
|
|
43
|
+
catch { }
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
db.close();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
decode(data) {
|
|
51
|
+
const op = JSON.parse(String(data));
|
|
52
|
+
if (!op || !/^[a-f0-9-]{36}$/.test(op.id) || !['planned', 'running', 'needs-attention', 'succeeded', 'cancelled'].includes(op.status)
|
|
53
|
+
|| !['skill-transfer-preview', 'skill-install-preview'].includes(op.plan?.kind) || op.plan.schemaVersion !== 1 || !Number.isSafeInteger(op.createdAt)
|
|
54
|
+
|| !Number.isSafeInteger(op.updatedAt) || (op.ownerPid !== undefined && (!Number.isSafeInteger(op.ownerPid) || op.ownerPid < 1))
|
|
55
|
+
|| (op.status === 'running' && !op.ownerPid)
|
|
56
|
+
|| (op.confirmedRevision && (op.confirmedRevision.contentPolicy !== 'bundle-v1' || op.confirmedRevision.hash !== (operationDirection(op.plan) === 'push' ? op.plan.expected.localHash : op.plan.expected.remote.hash) || op.confirmedRevision.cloudVersion !== op.plan.expected.remote.cloudVersion + (operationDirection(op.plan) === 'push' ? 1 : 0))))
|
|
57
|
+
throw new Error('Invalid operation record.');
|
|
58
|
+
return op;
|
|
59
|
+
}
|
|
60
|
+
async list() { return this.access(db => db.prepare('SELECT data FROM operations ORDER BY rowid DESC').all().map(row => this.decode(row.data))); }
|
|
61
|
+
async create(plan) {
|
|
62
|
+
if (!plan || plan.schemaVersion !== 1 || plan.outcome !== 'reviewable' || plan.blockers.length || JSON.stringify(plan).length > 2 * 1024 * 1024)
|
|
63
|
+
throw new Error('Only a reviewable plan can be queued.');
|
|
64
|
+
if (plan.kind === 'skill-install-preview') {
|
|
65
|
+
if (plan.expected.targetState !== 'missing' || plan.expected.localHash !== null || !plan.locator?.project || !plan.locator?.name)
|
|
66
|
+
throw new Error('Refresh the empty-destination installation plan.');
|
|
67
|
+
}
|
|
68
|
+
else if (plan.kind === 'skill-transfer-preview') {
|
|
69
|
+
const push = plan.direction === 'push';
|
|
70
|
+
if (!['push', 'pull'].includes(plan.direction) || plan.status !== (push ? 'local-changed' : 'remote-changed')
|
|
71
|
+
|| plan.binding.baseline?.hash !== (push ? plan.expected.remote.hash : plan.expected.localHash)
|
|
72
|
+
|| !/^[a-f0-9]{64}$/.test(plan.expected.localHash ?? '') || plan.expected.localHash === plan.expected.remote.hash
|
|
73
|
+
|| (!push && plan.ownership !== 'managed'))
|
|
74
|
+
throw new Error('Only a reviewed, owned transfer can be queued.');
|
|
75
|
+
}
|
|
76
|
+
else
|
|
77
|
+
throw new Error('Unsupported operation plan.');
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
const op = { id: randomUUID(), createdAt: now, updatedAt: now, status: 'planned', plan: structuredClone(plan) };
|
|
80
|
+
return this.access(db => {
|
|
81
|
+
if (Number(db.prepare('SELECT COUNT(*) AS count FROM operations').get().count) >= 10000)
|
|
82
|
+
throw new Error('Operation journal is full. Archive completed operations before queuing more.');
|
|
83
|
+
db.prepare('INSERT INTO operations VALUES (?,?)').run(op.id, JSON.stringify(op));
|
|
84
|
+
return op;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async confirm(id, revision) {
|
|
88
|
+
return this.access(db => {
|
|
89
|
+
const row = db.prepare('SELECT data FROM operations WHERE id=?').get(id);
|
|
90
|
+
if (!row)
|
|
91
|
+
throw new Error('Operation not found.');
|
|
92
|
+
const op = this.decode(row.data);
|
|
93
|
+
if (op.status !== 'running' || op.ownerPid !== process.pid)
|
|
94
|
+
throw new Error('Operation lease changed.');
|
|
95
|
+
if (revision.hash !== (operationDirection(op.plan) === 'push' ? op.plan.expected.localHash : op.plan.expected.remote.hash) || revision.cloudVersion !== op.plan.expected.remote.cloudVersion + (operationDirection(op.plan) === 'push' ? 1 : 0))
|
|
96
|
+
throw new Error('Unexpected confirmed revision.');
|
|
97
|
+
op.confirmedRevision = { ...revision };
|
|
98
|
+
op.updatedAt = Date.now();
|
|
99
|
+
db.prepare('UPDATE operations SET data=? WHERE id=?').run(JSON.stringify(op), id);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
async checkpoint(id, local) {
|
|
103
|
+
return this.access(db => {
|
|
104
|
+
const row = db.prepare('SELECT data FROM operations WHERE id=?').get(id);
|
|
105
|
+
if (!row)
|
|
106
|
+
throw new Error('Operation not found.');
|
|
107
|
+
const op = this.decode(row.data);
|
|
108
|
+
if (op.status !== 'running' || op.ownerPid !== process.pid)
|
|
109
|
+
throw new Error('Operation lease changed.');
|
|
110
|
+
op.local = structuredClone(local);
|
|
111
|
+
op.updatedAt = Date.now();
|
|
112
|
+
db.prepare('UPDATE operations SET data=? WHERE id=?').run(JSON.stringify(op), id);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
async claim(id, recover = false) {
|
|
116
|
+
return this.access(db => {
|
|
117
|
+
const row = db.prepare('SELECT data FROM operations WHERE id=?').get(id);
|
|
118
|
+
if (!row)
|
|
119
|
+
throw new Error('Operation not found.');
|
|
120
|
+
const op = this.decode(row.data);
|
|
121
|
+
if (op.status === 'succeeded' || op.status === 'cancelled')
|
|
122
|
+
return op;
|
|
123
|
+
const target = (o) => { const r = operationRemote(o.plan); return JSON.stringify([r.deployment, r.organizationId, r.projectId, r.skillId]); };
|
|
124
|
+
for (const other of db.prepare('SELECT data FROM operations WHERE id!=?').all(id).map(row => this.decode(row.data))) {
|
|
125
|
+
if (other.status === 'running' && (target(other) === target(op) || operationDestination(other.plan) === operationDestination(op.plan)))
|
|
126
|
+
throw new Error(`Cloud target is reserved by operation ${other.id}. Complete or recover that operation first.`);
|
|
127
|
+
}
|
|
128
|
+
if (op.status === 'running') {
|
|
129
|
+
if (!recover)
|
|
130
|
+
throw new Error('Operation is already running. Use recover after its worker has exited.');
|
|
131
|
+
if (op.ownerPid) {
|
|
132
|
+
let alive = true;
|
|
133
|
+
try {
|
|
134
|
+
process.kill(op.ownerPid, 0);
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
if (e.code === 'ESRCH')
|
|
138
|
+
alive = false;
|
|
139
|
+
}
|
|
140
|
+
if (alive)
|
|
141
|
+
throw new Error('The operation worker is still running; recovery refused.');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
op.status = 'running';
|
|
145
|
+
op.ownerPid = process.pid;
|
|
146
|
+
op.updatedAt = Date.now();
|
|
147
|
+
delete op.error;
|
|
148
|
+
db.prepare('UPDATE operations SET data=? WHERE id=?').run(JSON.stringify(op), id);
|
|
149
|
+
return op;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
async cancel(id) {
|
|
153
|
+
return this.access(db => {
|
|
154
|
+
const row = db.prepare('SELECT data FROM operations WHERE id=?').get(id);
|
|
155
|
+
if (!row)
|
|
156
|
+
throw new Error('Operation not found.');
|
|
157
|
+
const op = this.decode(row.data);
|
|
158
|
+
if (op.status === 'cancelled')
|
|
159
|
+
return op;
|
|
160
|
+
if (op.status !== 'planned')
|
|
161
|
+
throw new Error('Only an unstarted operation can be cancelled. Recover uncertain work before abandoning it.');
|
|
162
|
+
op.status = 'cancelled';
|
|
163
|
+
op.updatedAt = Date.now();
|
|
164
|
+
db.prepare('UPDATE operations SET data=? WHERE id=?').run(JSON.stringify(op), id);
|
|
165
|
+
return op;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
async finish(id, status, error) {
|
|
169
|
+
return this.access(db => {
|
|
170
|
+
const row = db.prepare('SELECT data FROM operations WHERE id=?').get(id);
|
|
171
|
+
if (!row)
|
|
172
|
+
throw new Error('Operation not found.');
|
|
173
|
+
const op = this.decode(row.data);
|
|
174
|
+
if (op.status !== 'running' || op.ownerPid !== process.pid)
|
|
175
|
+
throw new Error('Operation lease changed.');
|
|
176
|
+
op.status = status;
|
|
177
|
+
op.updatedAt = Date.now();
|
|
178
|
+
delete op.ownerPid;
|
|
179
|
+
if (error)
|
|
180
|
+
op.error = error.slice(0, 2000);
|
|
181
|
+
else
|
|
182
|
+
delete op.error;
|
|
183
|
+
db.prepare('UPDATE operations SET data=? WHERE id=?').run(JSON.stringify(op), id);
|
|
184
|
+
return op;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Idempotent by exact observed content/revision, never by names or a blind retry. */
|
|
189
|
+
export class SkillPushExecutor {
|
|
190
|
+
store;
|
|
191
|
+
journal;
|
|
192
|
+
cloud;
|
|
193
|
+
constructor(store, journal, cloud) {
|
|
194
|
+
this.store = store;
|
|
195
|
+
this.journal = journal;
|
|
196
|
+
this.cloud = cloud;
|
|
197
|
+
}
|
|
198
|
+
async apply(id, recover = false) {
|
|
199
|
+
const op = await this.journal.claim(id, recover);
|
|
200
|
+
if (op.status === 'succeeded' || op.status === 'cancelled')
|
|
201
|
+
return op;
|
|
202
|
+
try {
|
|
203
|
+
const plan = op.plan;
|
|
204
|
+
if (plan.kind !== 'skill-transfer-preview' || plan.direction !== 'push')
|
|
205
|
+
throw new Error('Use the local executor for this operation.');
|
|
206
|
+
const catalog = await this.store.read();
|
|
207
|
+
const binding = catalog.bindings.find(b => b.id === plan.binding.id);
|
|
208
|
+
const location = catalog.locations.find(l => l.id === plan.location.id);
|
|
209
|
+
if (!binding || !location || location.skillId !== binding.localSkillId || location.path !== plan.location.path
|
|
210
|
+
|| JSON.stringify({ ...binding, baseline: plan.binding.baseline }) !== JSON.stringify(plan.binding)
|
|
211
|
+
|| (!op.confirmedRevision && JSON.stringify(binding.baseline) !== JSON.stringify(plan.binding.baseline)))
|
|
212
|
+
throw new Error('Binding or location changed since review. Create a new plan.');
|
|
213
|
+
const { project, name } = binding.locator;
|
|
214
|
+
let remote = await this.cloud.snapshot(project, name);
|
|
215
|
+
assertSkillBindingSnapshot(binding, remote);
|
|
216
|
+
if (op.confirmedRevision) {
|
|
217
|
+
await this.store.transaction(c => {
|
|
218
|
+
const current = c.bindings.find(b => b.id === binding.id);
|
|
219
|
+
if (!current || JSON.stringify(current) !== JSON.stringify(binding))
|
|
220
|
+
throw new Error('Binding changed during recovery.');
|
|
221
|
+
if (JSON.stringify(current.baseline) === JSON.stringify(plan.binding.baseline))
|
|
222
|
+
current.baseline = { ...op.confirmedRevision };
|
|
223
|
+
});
|
|
224
|
+
return await this.journal.finish(id, 'succeeded');
|
|
225
|
+
}
|
|
226
|
+
if (!remote.supportsIdentityGuards)
|
|
227
|
+
throw new Error('This Sfora deployment does not support identity-checked transfers yet. Update the server before applying.');
|
|
228
|
+
const version = plan.expected.remote.cloudVersion;
|
|
229
|
+
const draft = plan.expected.draftRevision;
|
|
230
|
+
const hash = plan.expected.localHash;
|
|
231
|
+
const confirmed = () => remote.revision.cloudVersion === version + 1 && remote.revision.hash === hash;
|
|
232
|
+
if (!confirmed()) {
|
|
233
|
+
// A crash may have occurred after the draft committed but before its
|
|
234
|
+
// response arrived. Only the exact intended next draft can be resumed.
|
|
235
|
+
const saved = remote.revision.cloudVersion === version && remote.revision.hash === plan.expected.remote.hash
|
|
236
|
+
&& remote.draftRevision === draft + 1 && remote.hasDraft && remote.draftHash === hash;
|
|
237
|
+
if (!saved) {
|
|
238
|
+
const local = await (this.cloud.read ?? readSkillBundle)(location.path);
|
|
239
|
+
const current = planSkillTransfer({ direction: 'push', binding, location, local: { state: 'available', bundle: local }, remote, ownership: plan.ownership });
|
|
240
|
+
if (JSON.stringify(current) !== JSON.stringify(plan))
|
|
241
|
+
throw new Error('Skill changed since review. Create a new plan.');
|
|
242
|
+
const result = await this.cloud.saveDraft(project, { ...local, name }, version, draft, binding.remote.skillId);
|
|
243
|
+
if (result.draftRevision !== draft + 1)
|
|
244
|
+
throw new Error('Unexpected draft response. Inspect the operation before recovery.');
|
|
245
|
+
}
|
|
246
|
+
await this.cloud.publish(project, name, version, draft + 1, binding.remote.skillId);
|
|
247
|
+
remote = await this.cloud.snapshot(project, name);
|
|
248
|
+
assertSkillBindingSnapshot(binding, remote);
|
|
249
|
+
if (!confirmed())
|
|
250
|
+
throw new Error('Publication could not be confirmed at the expected revision. Inspect before recovery.');
|
|
251
|
+
}
|
|
252
|
+
// Baseline commit is deliberately after remote confirmation. If the
|
|
253
|
+
// journal completion is interrupted, recovery recognizes this baseline.
|
|
254
|
+
await this.journal.confirm(id, remote.revision);
|
|
255
|
+
await this.store.transaction(c => {
|
|
256
|
+
const current = c.bindings.find(b => b.id === binding.id);
|
|
257
|
+
if (!current || JSON.stringify(current) !== JSON.stringify(binding))
|
|
258
|
+
throw new Error('Binding changed during publication. Confirmation requires review.');
|
|
259
|
+
current.baseline = { ...remote.revision };
|
|
260
|
+
});
|
|
261
|
+
return await this.journal.finish(id, 'succeeded');
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
await this.journal.finish(id, 'needs-attention', error.message);
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { SkillOperationJournal, type SkillPushPorts } from './skill-operations.js';
|
|
2
|
+
import type { SkillStore } from './skill-store.js';
|
|
3
|
+
export interface SkillReview {
|
|
4
|
+
id: string;
|
|
5
|
+
action: 'push' | 'pull' | 'install' | 'link';
|
|
6
|
+
outcome: 'reviewable' | 'blocked' | 'no-op' | 'draft-needed';
|
|
7
|
+
name: string;
|
|
8
|
+
project: string;
|
|
9
|
+
scope: string;
|
|
10
|
+
path: string;
|
|
11
|
+
version?: number;
|
|
12
|
+
files: Array<{
|
|
13
|
+
path: string;
|
|
14
|
+
kind: 'added' | 'removed' | 'modified';
|
|
15
|
+
}>;
|
|
16
|
+
blockers: string[];
|
|
17
|
+
}
|
|
18
|
+
/** Read-only previews become durable operations only when the person confirms.
|
|
19
|
+
* Ephemeral review IDs are capability tokens owned by the desktop main process. */
|
|
20
|
+
export declare class SkillReviewService {
|
|
21
|
+
readonly store: SkillStore;
|
|
22
|
+
readonly journal: SkillOperationJournal;
|
|
23
|
+
readonly connect: (connection: string) => Promise<SkillPushPorts>;
|
|
24
|
+
readonly now: () => number;
|
|
25
|
+
private reviews;
|
|
26
|
+
constructor(store: SkillStore, journal: SkillOperationJournal, connect: (connection: string) => Promise<SkillPushPorts>, now?: () => number);
|
|
27
|
+
private remember;
|
|
28
|
+
private view;
|
|
29
|
+
transfer(input: {
|
|
30
|
+
locationId: string;
|
|
31
|
+
projectId: string;
|
|
32
|
+
connection: string;
|
|
33
|
+
direction: 'push' | 'pull';
|
|
34
|
+
}): Promise<SkillReview>;
|
|
35
|
+
install(input: {
|
|
36
|
+
projectId: string;
|
|
37
|
+
name: string;
|
|
38
|
+
connection: string;
|
|
39
|
+
destination: string;
|
|
40
|
+
}): Promise<SkillReview>;
|
|
41
|
+
confirm(id: string): Promise<{
|
|
42
|
+
status: string;
|
|
43
|
+
operationId?: string;
|
|
44
|
+
}>;
|
|
45
|
+
private apply;
|
|
46
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { readSkillBundle, inspectSkillTarget } from './skills.js';
|
|
3
|
+
import { SkillBindingService, planSkillInstallation, planSkillTransfer } from './skill-sync.js';
|
|
4
|
+
import { SkillOperationExecutor } from './skill-executor.js';
|
|
5
|
+
/** Read-only previews become durable operations only when the person confirms.
|
|
6
|
+
* Ephemeral review IDs are capability tokens owned by the desktop main process. */
|
|
7
|
+
export class SkillReviewService {
|
|
8
|
+
store;
|
|
9
|
+
journal;
|
|
10
|
+
connect;
|
|
11
|
+
now;
|
|
12
|
+
reviews = new Map();
|
|
13
|
+
constructor(store, journal, connect, now = Date.now) {
|
|
14
|
+
this.store = store;
|
|
15
|
+
this.journal = journal;
|
|
16
|
+
this.connect = connect;
|
|
17
|
+
this.now = now;
|
|
18
|
+
}
|
|
19
|
+
remember(entry) {
|
|
20
|
+
for (const [id, item] of this.reviews)
|
|
21
|
+
if (item.expiresAt < this.now() && (!item.operationId || item.result) && !item.running)
|
|
22
|
+
this.reviews.delete(id);
|
|
23
|
+
if (this.reviews.size >= 50)
|
|
24
|
+
throw new Error('Too many open reviews. Finish existing transfers or reopen the app.');
|
|
25
|
+
this.reviews.set(entry.view.id, { ...entry, expiresAt: this.now() + 10 * 60_000 });
|
|
26
|
+
return structuredClone(entry.view);
|
|
27
|
+
}
|
|
28
|
+
view(remote, path, action) {
|
|
29
|
+
return { id: randomUUID(), action, outcome: 'reviewable', name: remote.locator.name, project: remote.locator.project,
|
|
30
|
+
scope: `${remote.remote.deployment} · ${remote.remote.organizationId} · ${remote.accountId}`, path, version: remote.revision.cloudVersion, files: [], blockers: [] };
|
|
31
|
+
}
|
|
32
|
+
async transfer(input) {
|
|
33
|
+
if (!['push', 'pull'].includes(input.direction))
|
|
34
|
+
throw new Error('Choose Push to Sfora or Update local copy.');
|
|
35
|
+
const catalog = await this.store.read();
|
|
36
|
+
const location = catalog.locations.find(l => l.id === input.locationId);
|
|
37
|
+
if (!location)
|
|
38
|
+
throw new Error('This local skill is no longer tracked. Refresh and choose it again.');
|
|
39
|
+
const local = await readSkillBundle(location.path);
|
|
40
|
+
const cloud = await this.connect(input.connection);
|
|
41
|
+
const names = [...new Set(catalog.bindings.filter(b => b.localSkillId === location.skillId && b.remote.projectId === input.projectId).map(b => b.locator.name))];
|
|
42
|
+
let remote;
|
|
43
|
+
try {
|
|
44
|
+
remote = await cloud.snapshot(input.projectId, names.length === 1 ? names[0] : local.name);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
const code = error?.code;
|
|
48
|
+
if (names.length || !['SKILL_NOT_FOUND', 'SKILL_NOT_PUBLISHED'].includes(code ?? ''))
|
|
49
|
+
throw error;
|
|
50
|
+
if (input.direction === 'pull')
|
|
51
|
+
throw new Error('This skill has no published version to install. Open its project to review or publish a draft.');
|
|
52
|
+
return this.remember({ connection: input.connection, view: { id: randomUUID(), action: 'push', outcome: 'draft-needed', name: local.name, project: input.projectId, scope: input.connection, path: location.path,
|
|
53
|
+
files: local.files.map(file => ({ path: file.path, kind: 'added' })), blockers: ['For the first publication, review these local files in the project editor, save a draft, then publish it for your team.'] } });
|
|
54
|
+
}
|
|
55
|
+
if (remote.remote.projectId !== input.projectId)
|
|
56
|
+
throw new Error('The selected project resolved to a different identity.');
|
|
57
|
+
const binding = catalog.bindings.find(b => b.localSkillId === location.skillId && b.accountId === remote.accountId && b.remote.deployment === remote.remote.deployment && b.remote.organizationId === remote.remote.organizationId && b.remote.projectId === remote.remote.projectId);
|
|
58
|
+
const view = this.view(remote, location.path, input.direction);
|
|
59
|
+
if (!binding) {
|
|
60
|
+
if (local.hash !== remote.revision.hash) {
|
|
61
|
+
view.outcome = 'blocked';
|
|
62
|
+
view.blockers = ['These copies have not been linked and their contents differ. Open the project to review its existing skill, or install it into a new folder.'];
|
|
63
|
+
return this.remember({ view, connection: input.connection });
|
|
64
|
+
}
|
|
65
|
+
view.action = 'link';
|
|
66
|
+
return this.remember({ view, connection: input.connection, link: { localSkillId: location.skillId, locationId: location.id, snapshot: remote } });
|
|
67
|
+
}
|
|
68
|
+
const target = await inspectSkillTarget(location.path);
|
|
69
|
+
const plan = planSkillTransfer({ direction: input.direction, binding, location, local: { state: 'available', bundle: local }, remote, ownership: target.ownership });
|
|
70
|
+
view.outcome = plan.outcome;
|
|
71
|
+
view.files = plan.files.map(f => ({ path: f.path, kind: f.kind }));
|
|
72
|
+
view.blockers = plan.blockers;
|
|
73
|
+
return this.remember({ view, connection: input.connection, plan });
|
|
74
|
+
}
|
|
75
|
+
async install(input) {
|
|
76
|
+
const remote = await (await this.connect(input.connection)).snapshot(input.projectId, input.name);
|
|
77
|
+
if (remote.remote.projectId !== input.projectId)
|
|
78
|
+
throw new Error('The selected project resolved to a different identity.');
|
|
79
|
+
const plan = planSkillInstallation(remote, input.destination, await inspectSkillTarget(input.destination));
|
|
80
|
+
const view = this.view(remote, input.destination, 'install');
|
|
81
|
+
view.outcome = plan.outcome;
|
|
82
|
+
view.files = plan.files.map(f => ({ path: f.path, kind: f.kind }));
|
|
83
|
+
view.blockers = plan.blockers;
|
|
84
|
+
return this.remember({ view, connection: input.connection, plan });
|
|
85
|
+
}
|
|
86
|
+
async confirm(id) {
|
|
87
|
+
const entry = this.reviews.get(id);
|
|
88
|
+
if (!entry || (!entry.operationId && entry.expiresAt < this.now()))
|
|
89
|
+
throw new Error('This review expired. Review the current changes again.');
|
|
90
|
+
if (entry.view.outcome !== 'reviewable')
|
|
91
|
+
throw new Error('There are no applicable changes in this review.');
|
|
92
|
+
if (entry.result)
|
|
93
|
+
return entry.result;
|
|
94
|
+
if (entry.running)
|
|
95
|
+
return entry.running;
|
|
96
|
+
entry.running = this.apply(entry).then(result => { if (result.status === 'succeeded') {
|
|
97
|
+
entry.result = result;
|
|
98
|
+
delete entry.plan;
|
|
99
|
+
delete entry.link;
|
|
100
|
+
} return result; }).finally(() => { entry.running = undefined; });
|
|
101
|
+
return entry.running;
|
|
102
|
+
}
|
|
103
|
+
async apply(entry) {
|
|
104
|
+
const cloud = await this.connect(entry.connection);
|
|
105
|
+
if (entry.link) {
|
|
106
|
+
const { snapshot, locationId, localSkillId } = entry.link;
|
|
107
|
+
const location = (await this.store.read()).locations.find(l => l.id === locationId && l.skillId === localSkillId);
|
|
108
|
+
if (!location || location.path !== entry.view.path)
|
|
109
|
+
throw new Error('Local location changed. Review the copies again.');
|
|
110
|
+
const remote = await cloud.snapshot(snapshot.locator.project, snapshot.locator.name);
|
|
111
|
+
if (JSON.stringify(remote.remote) !== JSON.stringify(snapshot.remote) || remote.accountId !== snapshot.accountId || JSON.stringify(remote.revision) !== JSON.stringify(snapshot.revision))
|
|
112
|
+
throw new Error('Cloud identity or revision changed. Review the copies again.');
|
|
113
|
+
const local = await readSkillBundle(location.path);
|
|
114
|
+
if (local.hash !== remote.revision.hash)
|
|
115
|
+
throw new Error('Local files changed. Review the copies again.');
|
|
116
|
+
await new SkillBindingService(this.store).adopt(localSkillId, remote, local);
|
|
117
|
+
return { status: 'succeeded' };
|
|
118
|
+
}
|
|
119
|
+
if (!entry.plan)
|
|
120
|
+
throw new Error('Review has no applicable changes.');
|
|
121
|
+
const recovering = !!entry.operationId;
|
|
122
|
+
if (!entry.operationId)
|
|
123
|
+
entry.operationId = (await this.journal.create(entry.plan)).id;
|
|
124
|
+
try {
|
|
125
|
+
const operation = await new SkillOperationExecutor(this.store, this.journal, cloud).apply(entry.operationId, recovering);
|
|
126
|
+
return { status: operation.status, operationId: operation.id };
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
throw new Error(`${error.message} The result is saved in Transfers for recovery.`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { discoverSkills, type SkillSource } from './skill-discovery.js';
|
|
2
|
+
import { type RegisteredSkillRoot, type SkillStore } from './skill-store.js';
|
|
3
|
+
import { readSkillBundle } from './skills.js';
|
|
4
|
+
export declare const pathWithin: (root: string, path: string) => boolean;
|
|
5
|
+
export interface SkillServicePorts {
|
|
6
|
+
discover: typeof discoverSkills;
|
|
7
|
+
resolveDirectory(path: string): Promise<string>;
|
|
8
|
+
inspect: typeof readSkillBundle;
|
|
9
|
+
}
|
|
10
|
+
/** Node service shared by CLI and desktop. Its IDs are identity, not renderer grants. */
|
|
11
|
+
export declare class SkillService {
|
|
12
|
+
readonly store: SkillStore;
|
|
13
|
+
readonly sources: () => SkillSource[];
|
|
14
|
+
readonly ports: SkillServicePorts;
|
|
15
|
+
constructor(store?: SkillStore, sources?: () => SkillSource[], ports?: SkillServicePorts);
|
|
16
|
+
registerRoot(path: string, label?: string, scope?: "custom" | "project"): Promise<{
|
|
17
|
+
id: string;
|
|
18
|
+
path: string;
|
|
19
|
+
label: string;
|
|
20
|
+
depth: number;
|
|
21
|
+
scope?: "custom" | "project";
|
|
22
|
+
}>;
|
|
23
|
+
roots(): Promise<RegisteredSkillRoot[]>;
|
|
24
|
+
removeRoot(id: string): Promise<void>;
|
|
25
|
+
inventory(): Promise<{
|
|
26
|
+
skills: {
|
|
27
|
+
skillId: string;
|
|
28
|
+
locationId: string;
|
|
29
|
+
path: string;
|
|
30
|
+
name: string;
|
|
31
|
+
sources: string[];
|
|
32
|
+
aliases: import("./skill-domain.js").SkillAlias[];
|
|
33
|
+
hash: string | undefined;
|
|
34
|
+
fileCount: number | undefined;
|
|
35
|
+
error: string | undefined;
|
|
36
|
+
availability: "available" | "unavailable";
|
|
37
|
+
}[];
|
|
38
|
+
warnings: string[];
|
|
39
|
+
}>;
|
|
40
|
+
/** Warm reads use persisted observations; explicit inventory remains a full reconciliation. */
|
|
41
|
+
cachedInventory(): Promise<{
|
|
42
|
+
skills: {
|
|
43
|
+
skillId: string;
|
|
44
|
+
locationId: string;
|
|
45
|
+
path: string;
|
|
46
|
+
name: string;
|
|
47
|
+
sources: string[];
|
|
48
|
+
aliases: import("./skill-domain.js").SkillAlias[];
|
|
49
|
+
hash: string | undefined;
|
|
50
|
+
fileCount: number | undefined;
|
|
51
|
+
error: string | undefined;
|
|
52
|
+
availability: "available" | "unavailable";
|
|
53
|
+
}[];
|
|
54
|
+
warnings: string[];
|
|
55
|
+
}>;
|
|
56
|
+
discoverySources(): Promise<SkillSource[]>;
|
|
57
|
+
/** Re-read only affected known locations, preserving identity and last known bytes on failure. */
|
|
58
|
+
refreshLocations(ids: string[]): Promise<{
|
|
59
|
+
skills: {
|
|
60
|
+
skillId: string;
|
|
61
|
+
locationId: string;
|
|
62
|
+
path: string;
|
|
63
|
+
name: string;
|
|
64
|
+
sources: string[];
|
|
65
|
+
aliases: import("./skill-domain.js").SkillAlias[];
|
|
66
|
+
hash: string | undefined;
|
|
67
|
+
fileCount: number | undefined;
|
|
68
|
+
error: string | undefined;
|
|
69
|
+
availability: "available" | "unavailable";
|
|
70
|
+
}[];
|
|
71
|
+
warnings: string[];
|
|
72
|
+
}>;
|
|
73
|
+
/** Metadata-only explicit relocation. Never guesses identity from a name/hash or moves files. */
|
|
74
|
+
relocateLocation(id: string, path: string): Promise<{
|
|
75
|
+
id: string;
|
|
76
|
+
skillId: string;
|
|
77
|
+
path: string;
|
|
78
|
+
aliases: import("./skill-domain.js").SkillAlias[];
|
|
79
|
+
createdAt: number;
|
|
80
|
+
}>;
|
|
81
|
+
rebuild(): Promise<{
|
|
82
|
+
skills: {
|
|
83
|
+
skillId: string;
|
|
84
|
+
locationId: string;
|
|
85
|
+
path: string;
|
|
86
|
+
name: string;
|
|
87
|
+
sources: string[];
|
|
88
|
+
aliases: import("./skill-domain.js").SkillAlias[];
|
|
89
|
+
hash: string | undefined;
|
|
90
|
+
fileCount: number | undefined;
|
|
91
|
+
error: string | undefined;
|
|
92
|
+
availability: "available" | "unavailable";
|
|
93
|
+
}[];
|
|
94
|
+
warnings: string[];
|
|
95
|
+
}>;
|
|
96
|
+
}
|