sfora-cli 0.15.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 +62 -1
- package/dist/local-core/files.d.ts +1 -1
- package/dist/local-core/files.js +2 -2
- package/dist/local-core/index.d.ts +12 -0
- package/dist/local-core/index.js +11 -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-domain.js +1 -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 +10 -0
- package/dist/local-core/skills.js +49 -37
- package/dist/skills-client.d.ts +13 -2
- package/dist/skills-client.js +57 -5
- package/dist/skills-command.d.ts +1 -1
- package/dist/skills-command.js +152 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { mkdir, lstat, open, readdir, realpath, rename, rm, rmdir } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, join, isAbsolute } from 'node:path';
|
|
5
|
+
import { atomicCreate, atomicWrite, sha256, withLocalLock } from './files.js';
|
|
6
|
+
import { inspectSkillTarget, readInstallation, readSkillBundle } from './skills.js';
|
|
7
|
+
import { assertSkillBindingSnapshot, planSkillInstallation, planSkillTransfer, validatePublishedSkillSnapshot } from './skill-sync.js';
|
|
8
|
+
import { operationDestination } from './skill-operations.js';
|
|
9
|
+
const exists = async (path) => lstat(path).catch(e => { if (e.code === 'ENOENT')
|
|
10
|
+
return null; throw e; });
|
|
11
|
+
const readRegular = async (path, limit) => {
|
|
12
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
13
|
+
try {
|
|
14
|
+
const info = await handle.stat();
|
|
15
|
+
if (!info.isFile() || info.size > limit)
|
|
16
|
+
throw new Error('Invalid operation artifact.');
|
|
17
|
+
return await handle.readFile();
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
await handle.close();
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const receiptHash = (receipt) => receipt ? sha256(JSON.stringify(receipt)) : null;
|
|
24
|
+
async function directoryProof(path) {
|
|
25
|
+
const info = await lstat(path, { bigint: true });
|
|
26
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
27
|
+
throw new Error('Destination is no longer a directory.');
|
|
28
|
+
return { dev: String(info.dev), ino: String(info.ino) };
|
|
29
|
+
}
|
|
30
|
+
/** Recoverable local installation. Old bytes remain in an operation-specific backup.
|
|
31
|
+
* Destination creation and every file publication are exclusive: no foreign file is overwritten. */
|
|
32
|
+
export class SkillLocalExecutor {
|
|
33
|
+
store;
|
|
34
|
+
journal;
|
|
35
|
+
cloud;
|
|
36
|
+
constructor(store, journal, cloud) {
|
|
37
|
+
this.store = store;
|
|
38
|
+
this.journal = journal;
|
|
39
|
+
this.cloud = cloud;
|
|
40
|
+
}
|
|
41
|
+
async apply(id, recover = false) {
|
|
42
|
+
const op = await this.journal.claim(id, recover);
|
|
43
|
+
if (['succeeded', 'cancelled'].includes(op.status))
|
|
44
|
+
return op;
|
|
45
|
+
try {
|
|
46
|
+
const plan = op.plan;
|
|
47
|
+
if (plan.kind === 'skill-transfer-preview' && plan.direction !== 'pull')
|
|
48
|
+
throw new Error('Use the push executor for this plan.');
|
|
49
|
+
const destination = operationDestination(plan);
|
|
50
|
+
if (!isAbsolute(destination) || join(await realpath(dirname(destination)), basename(destination)) !== destination)
|
|
51
|
+
throw new Error('Destination parent changed since review.');
|
|
52
|
+
const lock = `${destination}.sfora-lock`;
|
|
53
|
+
if (recover && await exists(lock)) {
|
|
54
|
+
const info = await lstat(lock);
|
|
55
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
56
|
+
throw new Error('Unrecognized local lock; preserved.');
|
|
57
|
+
const entries = await readdir(lock);
|
|
58
|
+
if (entries.length !== 1 || entries[0] !== 'operation.json')
|
|
59
|
+
throw new Error('Local lock ownership is unknown; preserved for inspection.');
|
|
60
|
+
const owner = JSON.parse((await readRegular(join(lock, 'operation.json'), 1024)).toString());
|
|
61
|
+
if (owner.id !== op.id)
|
|
62
|
+
throw new Error('Local lock belongs to another operation.');
|
|
63
|
+
await rm(join(lock, 'operation.json'));
|
|
64
|
+
await rmdir(lock);
|
|
65
|
+
}
|
|
66
|
+
return await withLocalLock(destination, async () => {
|
|
67
|
+
await atomicCreate(join(lock, 'operation.json'), JSON.stringify({ id: op.id }));
|
|
68
|
+
return await this.run(op, destination);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
await this.journal.finish(id, 'needs-attention', error.message);
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async run(op, destination) {
|
|
77
|
+
const plan = op.plan;
|
|
78
|
+
if (plan.kind === 'skill-transfer-preview' && plan.direction !== 'pull')
|
|
79
|
+
throw new Error('Not a local plan.');
|
|
80
|
+
const artifactDirectory = join(dirname(this.journal.path), 'operation-artifacts', op.id);
|
|
81
|
+
const artifact = join(artifactDirectory, 'snapshot.json');
|
|
82
|
+
const backup = plan.kind === 'skill-transfer-preview' ? join(dirname(destination), `.${basename(destination)}.sfora-${op.id}.previous`) : undefined;
|
|
83
|
+
let local = op.local;
|
|
84
|
+
if (local && (local.destination !== destination || local.backup !== backup))
|
|
85
|
+
throw new Error('Operation checkpoint does not match its destination.');
|
|
86
|
+
let remote;
|
|
87
|
+
if (local) {
|
|
88
|
+
remote = JSON.parse((await readRegular(artifact, 30 * 1024 * 1024)).toString());
|
|
89
|
+
validatePublishedSkillSnapshot(remote);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const locator = plan.kind === 'skill-install-preview' ? plan.locator : plan.binding.locator;
|
|
93
|
+
remote = await this.cloud.snapshot(locator.project, locator.name);
|
|
94
|
+
validatePublishedSkillSnapshot(remote);
|
|
95
|
+
}
|
|
96
|
+
if (remote.revision.hash !== plan.expected.remote.hash || remote.revision.cloudVersion !== plan.expected.remote.cloudVersion)
|
|
97
|
+
throw new Error('Published revision differs from the reviewed plan.');
|
|
98
|
+
if (plan.kind === 'skill-transfer-preview')
|
|
99
|
+
assertSkillBindingSnapshot(plan.binding, remote);
|
|
100
|
+
else if (JSON.stringify({ ...remote.remote, accountId: remote.accountId }) !== JSON.stringify(plan.source))
|
|
101
|
+
throw new Error('Installation source identity changed.');
|
|
102
|
+
const before = await this.store.read();
|
|
103
|
+
if (plan.kind === 'skill-transfer-preview') {
|
|
104
|
+
const binding = before.bindings.find(b => b.id === plan.binding.id), location = before.locations.find(l => l.id === plan.location.id);
|
|
105
|
+
if (!binding || !location || location.path !== destination || location.skillId !== binding.localSkillId
|
|
106
|
+
|| JSON.stringify({ ...binding, baseline: plan.binding.baseline }) !== JSON.stringify(plan.binding)
|
|
107
|
+
|| (!op.confirmedRevision && JSON.stringify(binding.baseline) !== JSON.stringify(plan.binding.baseline)))
|
|
108
|
+
throw new Error('Binding or location changed since review.');
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
const location = before.locations.find(l => l.path === destination);
|
|
112
|
+
if (location && before.bindings.some(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 && b.remote.skillId !== remote.remote.skillId))
|
|
113
|
+
throw new Error('Destination history belongs to another cloud identity. Choose a new destination.');
|
|
114
|
+
}
|
|
115
|
+
const checkpoint = async (next) => { await this.journal.checkpoint(op.id, next); local = next; await this.cloud.checkpoint?.(next.phase); };
|
|
116
|
+
if (!local) {
|
|
117
|
+
const target = await inspectSkillTarget(destination);
|
|
118
|
+
const current = plan.kind === 'skill-install-preview' ? planSkillInstallation(remote, destination, target)
|
|
119
|
+
: planSkillTransfer({ direction: 'pull', binding: plan.binding, location: plan.location, remote, local: { state: 'available', bundle: await readSkillBundle(destination) }, ownership: target.ownership });
|
|
120
|
+
if (JSON.stringify(current) !== JSON.stringify(plan))
|
|
121
|
+
throw new Error('Local or cloud state changed since review.');
|
|
122
|
+
await mkdir(artifactDirectory, { recursive: true, mode: 0o700 });
|
|
123
|
+
if (await exists(artifact)) {
|
|
124
|
+
const previous = JSON.parse((await readRegular(artifact, 30 * 1024 * 1024)).toString());
|
|
125
|
+
if (JSON.stringify(previous) !== JSON.stringify(remote))
|
|
126
|
+
throw new Error('Staged snapshot differs; preserve it and create a new plan.');
|
|
127
|
+
}
|
|
128
|
+
else
|
|
129
|
+
await atomicCreate(artifact, JSON.stringify(remote));
|
|
130
|
+
await checkpoint({ phase: 'prepared', destination, backup, receiptBefore: receiptHash(await readInstallation(destination)) });
|
|
131
|
+
}
|
|
132
|
+
if (local.phase === 'prepared') {
|
|
133
|
+
if (backup) {
|
|
134
|
+
if (await exists(backup)) {
|
|
135
|
+
if (await exists(destination))
|
|
136
|
+
throw new Error('Both backup and destination exist without an ownership checkpoint; preserved for inspection.');
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
const target = await inspectSkillTarget(destination);
|
|
140
|
+
if (target.ownership !== 'managed' || target.hash !== plan.expected.localHash || receiptHash(target.receipt) !== local.receiptBefore)
|
|
141
|
+
throw new Error('Managed target changed before replacement.');
|
|
142
|
+
await rename(destination, backup);
|
|
143
|
+
await this.cloud.checkpoint?.('after-backup-rename');
|
|
144
|
+
}
|
|
145
|
+
if ((await readSkillBundle(backup, basename(destination))).hash !== plan.expected.localHash)
|
|
146
|
+
throw new Error('Backup contains external changes; preserved for inspection.');
|
|
147
|
+
}
|
|
148
|
+
else if (await exists(destination))
|
|
149
|
+
throw new Error('Installation destination became occupied.');
|
|
150
|
+
await checkpoint({ ...local, phase: 'backed-up' });
|
|
151
|
+
}
|
|
152
|
+
if (local.phase === 'backed-up') {
|
|
153
|
+
if (await exists(destination))
|
|
154
|
+
throw new Error('Destination exists without a recorded owner. Its contents were preserved; inspect before recovery.');
|
|
155
|
+
await mkdir(destination, { mode: 0o700 });
|
|
156
|
+
await checkpoint({ ...local, phase: 'owned', directory: await directoryProof(destination) });
|
|
157
|
+
}
|
|
158
|
+
const verifyOwner = async () => {
|
|
159
|
+
if (JSON.stringify(await directoryProof(destination)) !== JSON.stringify(local.directory))
|
|
160
|
+
throw new Error('Destination was replaced by another process.');
|
|
161
|
+
if (join(await realpath(dirname(destination)), basename(destination)) !== destination)
|
|
162
|
+
throw new Error('Destination parent changed.');
|
|
163
|
+
};
|
|
164
|
+
await verifyOwner();
|
|
165
|
+
if (local.phase === 'owned') {
|
|
166
|
+
for (const file of remote.bundle.files) {
|
|
167
|
+
await verifyOwner();
|
|
168
|
+
let parent = destination;
|
|
169
|
+
for (const part of file.path.split('/').slice(0, -1)) {
|
|
170
|
+
parent = join(parent, part);
|
|
171
|
+
await mkdir(parent).catch(e => { if (e.code !== 'EEXIST')
|
|
172
|
+
throw e; });
|
|
173
|
+
const info = await lstat(parent);
|
|
174
|
+
if (!info.isDirectory() || info.isSymbolicLink() || await realpath(parent) !== parent)
|
|
175
|
+
throw new Error('Skill subdirectory changed; refusing to follow a link.');
|
|
176
|
+
}
|
|
177
|
+
const path = join(destination, file.path);
|
|
178
|
+
try {
|
|
179
|
+
await atomicCreate(path, Buffer.from(file.contentBase64, 'base64'), file.executable ? 0o755 : 0o644);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (error.code !== 'EEXIST')
|
|
183
|
+
throw error;
|
|
184
|
+
const bytes = await readRegular(path, 5 * 1024 * 1024);
|
|
185
|
+
const info = await lstat(path);
|
|
186
|
+
if (sha256(bytes) !== file.sha256 || !!(info.mode & 0o111) !== file.executable)
|
|
187
|
+
throw new Error(`External changes in ${file.path}; preserved instead of overwritten.`);
|
|
188
|
+
}
|
|
189
|
+
await this.cloud.checkpoint?.(`file:${file.path}`);
|
|
190
|
+
}
|
|
191
|
+
await verifyOwner();
|
|
192
|
+
if ((await readSkillBundle(destination)).hash !== remote.bundle.hash)
|
|
193
|
+
throw new Error('Installed files include external changes; no baseline was recorded.');
|
|
194
|
+
const record = { schemaVersion: 1, path: destination, name: basename(destination), hash: remote.bundle.hash, installedAt: op.createdAt, source: `${remote.remote.deployment}/${remote.locator.project}/${remote.locator.name}`, sourceVersion: remote.revision.cloudVersion, operationId: op.id };
|
|
195
|
+
const receipt = await readInstallation(destination);
|
|
196
|
+
const receiptPath = join(dirname(destination), `.${basename(destination)}.sfora-install.json`);
|
|
197
|
+
if (JSON.stringify(receipt) !== JSON.stringify(record)) {
|
|
198
|
+
if (receiptHash(receipt) !== local.receiptBefore)
|
|
199
|
+
throw new Error('Installation ownership record changed.');
|
|
200
|
+
if (receipt)
|
|
201
|
+
await atomicWrite(receiptPath, JSON.stringify(record));
|
|
202
|
+
else
|
|
203
|
+
await atomicCreate(receiptPath, JSON.stringify(record));
|
|
204
|
+
}
|
|
205
|
+
await checkpoint({ ...local, phase: 'copied' });
|
|
206
|
+
}
|
|
207
|
+
await verifyOwner();
|
|
208
|
+
if ((await readSkillBundle(destination)).hash !== remote.bundle.hash)
|
|
209
|
+
throw new Error('Local files changed before confirmation; preserved.');
|
|
210
|
+
const finalReceipt = await readInstallation(destination);
|
|
211
|
+
if (finalReceipt?.operationId !== op.id || finalReceipt.hash !== remote.bundle.hash)
|
|
212
|
+
throw new Error('Installation ownership record changed before confirmation.');
|
|
213
|
+
await this.journal.confirm(op.id, remote.revision);
|
|
214
|
+
await this.store.transaction(c => {
|
|
215
|
+
if (plan.kind === 'skill-transfer-preview') {
|
|
216
|
+
const current = c.bindings.find(b => b.id === plan.binding.id);
|
|
217
|
+
if (!current || JSON.stringify({ ...current, baseline: plan.binding.baseline }) !== JSON.stringify(plan.binding))
|
|
218
|
+
throw new Error('Binding changed during installation.');
|
|
219
|
+
if (JSON.stringify(current.baseline) === JSON.stringify(plan.binding.baseline))
|
|
220
|
+
current.baseline = { ...remote.revision };
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
let root = c.roots.find(r => r.path === destination);
|
|
224
|
+
if (!root) {
|
|
225
|
+
root = { id: randomUUID(), path: destination, label: basename(destination), depth: 0, scope: 'custom' };
|
|
226
|
+
c.roots.push(root);
|
|
227
|
+
}
|
|
228
|
+
let location = c.locations.find(l => l.path === destination);
|
|
229
|
+
if (!location) {
|
|
230
|
+
const identity = { id: randomUUID(), createdAt: Date.now() };
|
|
231
|
+
c.identities.push(identity);
|
|
232
|
+
location = { id: randomUUID(), skillId: identity.id, path: destination, createdAt: Date.now(), aliases: [] };
|
|
233
|
+
c.locations.push(location);
|
|
234
|
+
}
|
|
235
|
+
if (!location.aliases.some(a => a.sourceId === root.id))
|
|
236
|
+
location.aliases.push({ sourceId: root.id, path: destination, label: root.label, kind: 'custom' });
|
|
237
|
+
const binding = c.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);
|
|
238
|
+
if (binding && binding.remote.skillId !== remote.remote.skillId)
|
|
239
|
+
throw new Error('A different cloud identity claimed this destination.');
|
|
240
|
+
if (!binding)
|
|
241
|
+
c.bindings.push({ id: randomUUID(), localSkillId: location.skillId, remote: { ...remote.remote }, accountId: remote.accountId, locator: { ...remote.locator }, baseline: { ...remote.revision } });
|
|
242
|
+
else if (!op.confirmedRevision)
|
|
243
|
+
binding.baseline = { ...remote.revision };
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
// Retain the original directory and staged snapshot for explicit inspection.
|
|
247
|
+
return this.journal.finish(op.id, 'succeeded');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readSkillBundle } from './skills.js';
|
|
2
|
+
import { type PublishedSkillSnapshot, type SkillTransferPlan, type SkillInstallPlan } from './skill-sync.js';
|
|
3
|
+
import type { SkillStore } from './skill-store.js';
|
|
4
|
+
import type { SkillRevision } from './skill-domain.js';
|
|
5
|
+
export type SkillOperationPlan = SkillTransferPlan | SkillInstallPlan;
|
|
6
|
+
export interface LocalOperationCheckpoint {
|
|
7
|
+
phase: "prepared" | "backed-up" | "owned" | "copied";
|
|
8
|
+
destination: string;
|
|
9
|
+
backup?: string;
|
|
10
|
+
directory?: {
|
|
11
|
+
dev: string;
|
|
12
|
+
ino: string;
|
|
13
|
+
};
|
|
14
|
+
receiptBefore: string | null;
|
|
15
|
+
}
|
|
16
|
+
export declare const operationDirection: (plan: SkillOperationPlan) => "push" | "pull" | "install";
|
|
17
|
+
export declare const operationRemote: (plan: SkillOperationPlan) => import("./skill-domain.js").CloudSkillIdentity & {
|
|
18
|
+
projectId: string;
|
|
19
|
+
};
|
|
20
|
+
export declare const operationDestination: (plan: SkillOperationPlan) => string;
|
|
21
|
+
export interface SkillTransferOperation {
|
|
22
|
+
id: string;
|
|
23
|
+
createdAt: number;
|
|
24
|
+
updatedAt: number;
|
|
25
|
+
plan: SkillOperationPlan;
|
|
26
|
+
status: 'planned' | 'running' | 'needs-attention' | 'succeeded' | 'cancelled';
|
|
27
|
+
ownerPid?: number;
|
|
28
|
+
error?: string;
|
|
29
|
+
confirmedRevision?: SkillRevision;
|
|
30
|
+
local?: LocalOperationCheckpoint;
|
|
31
|
+
}
|
|
32
|
+
/** Separate versioned journal: intent commits before network I/O. A dead worker never retains a SQLite lock. */
|
|
33
|
+
export declare class SkillOperationJournal {
|
|
34
|
+
readonly path: string;
|
|
35
|
+
constructor(path: string);
|
|
36
|
+
private access;
|
|
37
|
+
private decode;
|
|
38
|
+
list(): Promise<SkillTransferOperation[]>;
|
|
39
|
+
create(plan: SkillOperationPlan): Promise<SkillTransferOperation>;
|
|
40
|
+
confirm(id: string, revision: SkillRevision): Promise<void>;
|
|
41
|
+
checkpoint(id: string, local: LocalOperationCheckpoint): Promise<void>;
|
|
42
|
+
claim(id: string, recover?: boolean): Promise<SkillTransferOperation>;
|
|
43
|
+
cancel(id: string): Promise<SkillTransferOperation>;
|
|
44
|
+
finish(id: string, status: 'succeeded' | 'needs-attention', error?: string): Promise<SkillTransferOperation>;
|
|
45
|
+
}
|
|
46
|
+
export interface SkillPushPorts {
|
|
47
|
+
snapshot(project: string, name: string): Promise<PublishedSkillSnapshot>;
|
|
48
|
+
saveDraft(project: string, bundle: Awaited<ReturnType<typeof readSkillBundle>>, version: number, draftRevision: number, skillId: string): Promise<{
|
|
49
|
+
draftRevision: number;
|
|
50
|
+
}>;
|
|
51
|
+
publish(project: string, name: string, version: number, draftRevision: number, skillId: string): Promise<unknown>;
|
|
52
|
+
read?: typeof readSkillBundle;
|
|
53
|
+
}
|
|
54
|
+
/** Idempotent by exact observed content/revision, never by names or a blind retry. */
|
|
55
|
+
export declare class SkillPushExecutor {
|
|
56
|
+
readonly store: SkillStore;
|
|
57
|
+
readonly journal: SkillOperationJournal;
|
|
58
|
+
readonly cloud: SkillPushPorts;
|
|
59
|
+
constructor(store: SkillStore, journal: SkillOperationJournal, cloud: SkillPushPorts);
|
|
60
|
+
apply(id: string, recover?: boolean): Promise<SkillTransferOperation>;
|
|
61
|
+
}
|
|
@@ -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
|
+
}
|