@jini-ai/agent-plugins 0.4.0 → 0.4.2
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 +11 -0
- package/README.md +19 -0
- package/core/persistent-state.d.ts +25 -0
- package/core/persistent-state.js +260 -0
- package/dist/lifecycle/activation.js +38 -166
- package/dist/lifecycle/bundled-digests.d.ts +6 -0
- package/dist/lifecycle/bundled-digests.js +156 -0
- package/dist/lifecycle/bundled-source-archive.d.ts +3 -0
- package/dist/lifecycle/bundled-source-archive.js +74 -0
- package/dist/lifecycle/contribution-loader.d.ts +102 -0
- package/dist/lifecycle/contribution-loader.js +92 -0
- package/dist/lifecycle/fetch-archive.d.ts +1 -2
- package/dist/lifecycle/index.d.ts +34 -4
- package/dist/lifecycle/index.js +19 -1
- package/dist/lifecycle/install-from-url.d.ts +2 -5
- package/dist/lifecycle/install-from-url.js +2 -5
- package/dist/lifecycle/install.d.ts +57 -2
- package/dist/lifecycle/install.js +228 -13
- package/dist/lifecycle/layout.d.ts +10 -0
- package/dist/lifecycle/layout.js +9 -1
- package/dist/lifecycle/manifest.d.ts +3 -9
- package/dist/lifecycle/manifest.js +4 -11
- package/dist/lifecycle/package-paths.d.ts +2 -0
- package/dist/lifecycle/package-paths.js +62 -0
- package/dist/lifecycle/persistent-state.d.ts +24 -0
- package/dist/lifecycle/persistent-state.js +32 -0
- package/dist/lifecycle/ports.d.ts +15 -3
- package/dist/lifecycle/resolve-agent-plugin-refs.d.ts +14 -2
- package/dist/lifecycle/resolve-agent-plugin-refs.js +173 -10
- package/dist/lifecycle/retire-bundled.d.ts +50 -1
- package/dist/lifecycle/retire-bundled.js +10 -3
- package/dist/lifecycle/search.d.ts +63 -0
- package/dist/lifecycle/search.js +29 -0
- package/dist/lifecycle/seed-bundled.d.ts +18 -1
- package/dist/lifecycle/seed-bundled.js +139 -1
- package/dist/lifecycle/set-enabled.d.ts +7 -0
- package/dist/lifecycle/set-enabled.js +6 -1
- package/dist/lifecycle/trusted-plugin-files.d.ts +38 -0
- package/dist/lifecycle/trusted-plugin-files.js +19 -1
- package/dist/lifecycle/types.d.ts +12 -0
- package/dist/lifecycle/uninstall.d.ts +30 -0
- package/dist/lifecycle/uninstall.js +232 -4
- package/dist/lifecycle/yauzl-archive-reader.d.ts +0 -1
- package/dist/lifecycle/yauzl-archive-reader.js +5 -4
- package/package.json +34 -9
- package/persistent-state.d.ts +25 -0
- package/persistent-state.js +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Tovu clean-up release (2026-10-08): code Tovu moved into Jini, plus the Jini clean-up (see the commit log since 0.4.1).
|
|
8
|
+
|
|
9
|
+
## 0.4.1 — 2026-10-04
|
|
10
|
+
|
|
11
|
+
- Layout B per-plugin state with a build-free `@jini-ai/agent-plugins/persistent-state` entry; migration completes after quarantining rejected entries.
|
|
12
|
+
- A directory-shaped ZIP entry with a symlink mode is still treated as a symlink.
|
|
13
|
+
|
|
3
14
|
## Unreleased — lifecycle consumer adoption
|
|
4
15
|
|
|
5
16
|
- Expose narrow `createAgentPluginActivations(required, optional)` composition without
|
package/README.md
CHANGED
|
@@ -259,3 +259,22 @@ For a host that needs only activation, use `createAgentPluginActivations` with
|
|
|
259
259
|
`createNodeAgentPluginEffects({})` instead of supplying unused network/provisioning
|
|
260
260
|
ports. Retain one returned object for the process lifetime. See `API.md` for the
|
|
261
261
|
object-shaped reads, filters and optional legacy metadata reader.
|
|
262
|
+
|
|
263
|
+
### Persistent plugin state (Layout B)
|
|
264
|
+
|
|
265
|
+
`@jini-ai/agent-plugins/persistent-state` is a build-free Node entry with declarations.
|
|
266
|
+
`pluginStatePaths({ workspaceRoot, pluginId })` gives one plugin's `package/sha256/`,
|
|
267
|
+
`memory/learned/`, `memory/notes/` and `data/` paths. `createPluginMemory` injects the
|
|
268
|
+
filesystem, the package containment primitive and a cross-process lock. Its plugin-facing
|
|
269
|
+
`learned` capability cannot select another id or write user notes. Defaults: 1 MiB learned,
|
|
270
|
+
16 KiB notes, 128 files per tier; callers may lower these caps.
|
|
271
|
+
|
|
272
|
+
`migratePluginLayout` moves legacy packages, memory and PLUGIN_DATA, preserving conflicts
|
|
273
|
+
and malformed installs for human repair. It records version 2 only after success and repairs
|
|
274
|
+
frozen package roots after interrupted moves. Lock files use workspace staging scratch.
|
|
275
|
+
Uninstall keeps persistent state by default; deleting it requires a matching confirmed preview.
|
|
276
|
+
The reserved workspace directory ids `staging`, `packages`, `memory`, and `data` are refused
|
|
277
|
+
as plugin ids so package state cannot overlap migration inputs or installation scratch.
|
|
278
|
+
|
|
279
|
+
The `dev.tovu.memory` proposal is consumer policy and is not implemented by this neutral entry.
|
|
280
|
+
No version bump or publishing accompanies this workspace implementation.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type PluginMemoryKind = 'learned' | 'notes';
|
|
2
|
+
export interface PluginStatePaths { root: string; packages: string; data: string; learned: string; notes: string; }
|
|
3
|
+
export const DEFAULT_PLUGIN_MEMORY_LIMITS: Readonly<{ learned: number; notes: number; files: number }>;
|
|
4
|
+
export interface PluginStateEffects {
|
|
5
|
+
filesystem: Pick<typeof import('node:fs/promises'), 'mkdir' | 'rmdir' | 'readdir' | 'open' | 'readFile' | 'rename' | 'rm' | 'stat' | 'chmod' | 'realpath'>;
|
|
6
|
+
contain(required: { root: string; entryPath: string }): Promise<string>;
|
|
7
|
+
withLock<T>(required: { lockPath: string; run(): Promise<T> }): Promise<T>;
|
|
8
|
+
}
|
|
9
|
+
export interface PluginMemoryFile { relativePath: string; text: string; }
|
|
10
|
+
export interface PluginMemory {
|
|
11
|
+
list(required: { kind: PluginMemoryKind }, optional?: {}): Promise<PluginMemoryFile[]>;
|
|
12
|
+
read(required: { kind: PluginMemoryKind; entryPath: string }, optional?: {}): Promise<string>;
|
|
13
|
+
writeNote(required: { entryPath: string; text: string }, optional?: {}): Promise<{ relativePath: string; bytes: number }>;
|
|
14
|
+
learned: {
|
|
15
|
+
read(required: { entryPath: string }, optional?: {}): Promise<string>;
|
|
16
|
+
write(required: { entryPath: string; text: string }, optional?: {}): Promise<{ relativePath: string; bytes: number }>;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function pluginStatePaths(required: { workspaceRoot: string; pluginId: string }, optional?: {}): PluginStatePaths;
|
|
20
|
+
export function createPluginMemory(required: PluginStateEffects & { workspaceRoot: string; pluginId: string }, optional?: { limits?: Partial<typeof DEFAULT_PLUGIN_MEMORY_LIMITS> }): PluginMemory;
|
|
21
|
+
export function migratePluginLayout(required: PluginStateEffects & { workspaceRoot: string; parsePluginId(required: { value: unknown }): string; onEvent?(required: { event: string; message: string }): void }, optional?: {}): Promise<{ complete: boolean; moved: number }>;
|
|
22
|
+
|
|
23
|
+
export function assertPluginStatePath(required: Pick<PluginStateEffects, "filesystem" | "contain"> & { workspaceRoot: string; entryPath: string }, optional?: {}): Promise<string>;
|
|
24
|
+
|
|
25
|
+
export function withPluginStateLock<T>(required: PluginStateEffects & { workspaceRoot: string; pluginId: string; run(): Promise<T> }, optional?: {}): Promise<T>;
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/** Build-free Node entry: effects and policy belong to the host; no product namespaces here. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const SAFE_ID = /^[a-z0-9]+(?:[-.][a-z0-9]+)*$/;
|
|
6
|
+
const DIGEST = /^[a-f0-9]{64}$/;
|
|
7
|
+
export const DEFAULT_PLUGIN_MEMORY_LIMITS = Object.freeze({ learned: 1024 * 1024, notes: 16 * 1024, files: 128 });
|
|
8
|
+
export function pluginStatePaths({ workspaceRoot, pluginId }, _optional = {}) {
|
|
9
|
+
if (!path.isAbsolute(workspaceRoot) || !SAFE_ID.test(pluginId) || pluginId.length > 64 || ['staging', 'packages', 'memory', 'data'].includes(pluginId)) throw new Error('Invalid plugin state root or id');
|
|
10
|
+
const root = path.join(workspaceRoot, pluginId);
|
|
11
|
+
return { root, packages: path.join(root, 'package', 'sha256'), data: path.join(root, 'data'),
|
|
12
|
+
learned: path.join(root, 'memory', 'learned'), notes: path.join(root, 'memory', 'notes') };
|
|
13
|
+
}
|
|
14
|
+
/** Containment plus ownership: a sibling plugin inside the workspace is still outside this one. */
|
|
15
|
+
export async function assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath }, _optional = {}) {
|
|
16
|
+
const absolute = await contain({ root: workspaceRoot, entryPath });
|
|
17
|
+
const realWorkspace = await fs.realpath(workspaceRoot);
|
|
18
|
+
const relative = path.relative(realWorkspace, absolute);
|
|
19
|
+
let current = realWorkspace;
|
|
20
|
+
for (const segment of relative.split(path.sep)) {
|
|
21
|
+
current = path.join(current, segment);
|
|
22
|
+
try { if (await fs.realpath(current) !== current) throw new Error('Plugin state ancestors must not be symlinks'); }
|
|
23
|
+
catch (error) { if (code(error) === 'ENOENT') break; throw error; }
|
|
24
|
+
}
|
|
25
|
+
return absolute;
|
|
26
|
+
}
|
|
27
|
+
function code(error) { return error && typeof error === 'object' ? error.code : undefined; }
|
|
28
|
+
|
|
29
|
+
/** Lock files are workspace scratch so uninstall cannot delete a lock another process holds. */
|
|
30
|
+
export async function withPluginStateLock({ filesystem: fs, contain, workspaceRoot, pluginId, withLock, run }, _optional = {}) {
|
|
31
|
+
pluginStatePaths({ workspaceRoot, pluginId });
|
|
32
|
+
await fs.mkdir(workspaceRoot, { recursive: true, mode: 0o700 });
|
|
33
|
+
const lockPath = await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: `staging/.plugin-state-${pluginId}.lock` });
|
|
34
|
+
return withLock({ lockPath, run });
|
|
35
|
+
}
|
|
36
|
+
/** Each instance is bound to one plugin. No read/write method accepts an id or arbitrary root. */
|
|
37
|
+
export function createPluginMemory({ filesystem: fs, contain, workspaceRoot, pluginId, withLock }, optional = {}) {
|
|
38
|
+
const paths = pluginStatePaths({ workspaceRoot, pluginId });
|
|
39
|
+
const limits = { ...DEFAULT_PLUGIN_MEMORY_LIMITS, ...optional.limits };
|
|
40
|
+
for (const kind of ['learned', 'notes', 'files']) {
|
|
41
|
+
if (!Number.isSafeInteger(limits[kind]) || limits[kind] <= 0 || limits[kind] > DEFAULT_PLUGIN_MEMORY_LIMITS[kind]) throw new Error('Memory caps must be positive and cannot exceed host ceilings');
|
|
42
|
+
}
|
|
43
|
+
function checkKind(kind) { if (kind !== 'learned' && kind !== 'notes') throw new Error('Invalid memory kind'); }
|
|
44
|
+
async function root(kind, create = false) {
|
|
45
|
+
checkKind(kind);
|
|
46
|
+
if (create) await fs.mkdir(workspaceRoot, { recursive: true, mode: 0o700 });
|
|
47
|
+
await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.relative(workspaceRoot, paths[kind]) });
|
|
48
|
+
if (create) await fs.mkdir(paths[kind], { recursive: true, mode: 0o700 });
|
|
49
|
+
return paths[kind];
|
|
50
|
+
}
|
|
51
|
+
async function readText(filename, cap) {
|
|
52
|
+
// A bounded read protects against a concurrent file growing after stat. Invalid UTF-8 is refused.
|
|
53
|
+
const handle = await fs.open(filename, 'r');
|
|
54
|
+
try {
|
|
55
|
+
if (!(await handle.stat()).isFile()) throw new Error('Memory must contain regular UTF-8 files');
|
|
56
|
+
const bytes = Buffer.alloc(cap + 1);
|
|
57
|
+
let size = 0;
|
|
58
|
+
while (size < bytes.length) {
|
|
59
|
+
const result = await handle.read(bytes, size, bytes.length - size, size);
|
|
60
|
+
if (result.bytesRead === 0) break;
|
|
61
|
+
size += result.bytesRead;
|
|
62
|
+
}
|
|
63
|
+
if (size > cap) throw new Error('Memory byte cap exceeded');
|
|
64
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, size));
|
|
65
|
+
if (text.includes('\0')) throw new Error('Memory must be UTF-8 text');
|
|
66
|
+
return text;
|
|
67
|
+
} finally { await handle.close(); }
|
|
68
|
+
}
|
|
69
|
+
async function list({ kind }, _optional = {}) {
|
|
70
|
+
const base = await root(kind).catch(error => { if (code(error) === 'ENOENT') return undefined; throw error; });
|
|
71
|
+
if (!base) return [];
|
|
72
|
+
const files = [];
|
|
73
|
+
let total = 0, entriesSeen = 0;
|
|
74
|
+
async function walk(prefix) {
|
|
75
|
+
let entries;
|
|
76
|
+
try { entries = await fs.readdir(path.join(base, prefix), { withFileTypes: true }); }
|
|
77
|
+
catch (error) { if (code(error) === 'ENOENT' && prefix === '') return; throw error; }
|
|
78
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
79
|
+
if (++entriesSeen > limits.files * 4) throw new Error('Memory entry cap exceeded');
|
|
80
|
+
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
81
|
+
const filename = await contain({ root: base, entryPath: relativePath });
|
|
82
|
+
if (entry.isDirectory()) await walk(relativePath);
|
|
83
|
+
else if (entry.isFile()) {
|
|
84
|
+
if (files.length >= limits.files) throw new Error('Memory file cap exceeded');
|
|
85
|
+
const text = await readText(filename, limits[kind] - total);
|
|
86
|
+
total += Buffer.byteLength(text, 'utf8');
|
|
87
|
+
files.push({ relativePath, text });
|
|
88
|
+
} else throw new Error('Memory symlinks and special files are refused');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
await walk('');
|
|
92
|
+
return files;
|
|
93
|
+
}
|
|
94
|
+
async function read({ kind, entryPath }, _optional = {}) {
|
|
95
|
+
const base = await root(kind);
|
|
96
|
+
return readText(await contain({ root: base, entryPath }), limits[kind]);
|
|
97
|
+
}
|
|
98
|
+
async function write({ kind, entryPath, text }, _optional = {}) {
|
|
99
|
+
checkKind(kind);
|
|
100
|
+
if (typeof text !== 'string' || text.includes('\0') || Buffer.from(text, 'utf8').toString('utf8') !== text) throw new Error('Memory must be valid UTF-8 text');
|
|
101
|
+
if (Buffer.byteLength(text, 'utf8') > limits[kind]) throw new Error('Memory byte cap exceeded');
|
|
102
|
+
// The same lock protects total-size accounting for all processes using this plugin.
|
|
103
|
+
return withPluginStateLock({ filesystem: fs, contain, workspaceRoot, pluginId, withLock, run: async () => {
|
|
104
|
+
const base = await root(kind, true);
|
|
105
|
+
const destination = await contain({ root: base, entryPath });
|
|
106
|
+
const normalized = path.relative(base, destination).split(path.sep).join('/');
|
|
107
|
+
const files = await list({ kind });
|
|
108
|
+
const existing = files.find(file => file.relativePath === normalized);
|
|
109
|
+
const total = files.reduce((sum, file) => sum + Buffer.byteLength(file.text, 'utf8'), 0);
|
|
110
|
+
if ((!existing && files.length >= limits.files) || total - Buffer.byteLength(existing?.text ?? '', 'utf8') + Buffer.byteLength(text, 'utf8') > limits[kind]) throw new Error('Memory cap exceeded');
|
|
111
|
+
await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
112
|
+
await contain({ root: base, entryPath });
|
|
113
|
+
const temporary = path.join(path.dirname(destination), `.memory-${randomUUID()}.tmp`);
|
|
114
|
+
const handle = await fs.open(temporary, 'wx', 0o600);
|
|
115
|
+
try {
|
|
116
|
+
try { await handle.writeFile(text, 'utf8'); await handle.sync(); } finally { await handle.close(); }
|
|
117
|
+
await fs.rename(temporary, destination);
|
|
118
|
+
} finally { await fs.rm(temporary, { force: true }); }
|
|
119
|
+
return { relativePath: normalized, bytes: Buffer.byteLength(text, 'utf8') };
|
|
120
|
+
} });
|
|
121
|
+
}
|
|
122
|
+
// The plugin-facing capability has no notes writer, even if an input tries to supply kind/id.
|
|
123
|
+
return { list, read, writeNote: ({ entryPath, text }, optional = {}) => write({ kind: 'notes', entryPath, text }, optional),
|
|
124
|
+
learned: { read: ({ entryPath }, optional = {}) => read({ kind: 'learned', entryPath }, optional),
|
|
125
|
+
write: ({ entryPath, text }, optional = {}) => write({ kind: 'learned', entryPath, text }, optional) } };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Interrupted moves are resumable; refused legacy entries are kept in quarantine and reported. */
|
|
129
|
+
export async function migratePluginLayout({ filesystem: fs, contain, workspaceRoot, parsePluginId, withLock, onEvent }, _optional = {}) {
|
|
130
|
+
await fs.mkdir(workspaceRoot, { recursive: true, mode: 0o700 });
|
|
131
|
+
// Containment returns canonical paths; relative moves must use the same root (e.g. /var -> /private/var).
|
|
132
|
+
workspaceRoot = await fs.realpath(workspaceRoot);
|
|
133
|
+
const marker = path.join(workspaceRoot, '.agent-plugin-layout-migrated');
|
|
134
|
+
const emit = (event, message) => onEvent?.({ event, message });
|
|
135
|
+
return withLock({ lockPath: `${marker}.lock`, run: async () => {
|
|
136
|
+
await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.basename(marker) });
|
|
137
|
+
try { if (JSON.parse(await fs.readFile(marker, 'utf8')).layoutVersion === 2) return { complete: true, moved: 0 }; }
|
|
138
|
+
catch (error) { if (code(error) !== 'ENOENT') emit('migration-marker-invalid', String(error)); }
|
|
139
|
+
let complete = true, moved = 0;
|
|
140
|
+
let quarantineRoot;
|
|
141
|
+
async function entries(directory) {
|
|
142
|
+
try { return await fs.readdir(directory, { withFileTypes: true }); }
|
|
143
|
+
catch (error) { if (code(error) === 'ENOENT') return []; throw error; }
|
|
144
|
+
}
|
|
145
|
+
async function renameEntry({ source, destination, frozen }, _optional = {}) {
|
|
146
|
+
await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
147
|
+
const mode = frozen ? (await fs.stat(source)).mode & 0o777 : undefined;
|
|
148
|
+
if (frozen) await fs.chmod(source, mode | 0o700);
|
|
149
|
+
try { await fs.rename(source, destination); }
|
|
150
|
+
catch (error) { if (frozen) await fs.chmod(source, mode).catch(() => undefined); throw error; }
|
|
151
|
+
if (frozen) await fs.chmod(destination, mode);
|
|
152
|
+
}
|
|
153
|
+
async function quarantine({ source, reason, frozen = false }, _optional = {}) {
|
|
154
|
+
// Validate ancestors, then rename the entry itself: even an unsafe symlink is preserved,
|
|
155
|
+
// never followed into another plugin or outside the workspace.
|
|
156
|
+
await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.relative(workspaceRoot, path.dirname(source)) });
|
|
157
|
+
if (!quarantineRoot) {
|
|
158
|
+
const base = await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: 'staging/legacy-quarantine' });
|
|
159
|
+
await fs.mkdir(base, { recursive: true, mode: 0o700 });
|
|
160
|
+
const candidate = path.join(base, `${new Date().toISOString().replaceAll(':', '-')}-${randomUUID()}`);
|
|
161
|
+
// Exclusive creation prevents a retry or another boot from overwriting saved bytes.
|
|
162
|
+
await fs.mkdir(candidate, { mode: 0o700 });
|
|
163
|
+
quarantineRoot = candidate;
|
|
164
|
+
}
|
|
165
|
+
const destinationParent = await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot,
|
|
166
|
+
entryPath: path.relative(workspaceRoot, path.join(quarantineRoot, path.relative(workspaceRoot, path.dirname(source)))) });
|
|
167
|
+
// Keep the native directory-entry name verbatim, even if it is not a valid portable package
|
|
168
|
+
// path (e.g. a literal backslash on POSIX). The fresh quarantine tree has no existing leaf.
|
|
169
|
+
const destination = path.join(destinationParent, path.basename(source));
|
|
170
|
+
await renameEntry({ source, destination, frozen });
|
|
171
|
+
emit('migration-quarantined', `${source} -> ${destination}: ${reason}`);
|
|
172
|
+
}
|
|
173
|
+
async function move(source, destination, frozen = false) {
|
|
174
|
+
await contain({ root: workspaceRoot, entryPath: path.relative(workspaceRoot, source) });
|
|
175
|
+
await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.relative(workspaceRoot, destination) });
|
|
176
|
+
let exists = false;
|
|
177
|
+
try { await fs.stat(destination); exists = true; }
|
|
178
|
+
catch (error) { if (code(error) !== 'ENOENT') throw error; }
|
|
179
|
+
if (exists) {
|
|
180
|
+
// The Layout B copy wins. This runs under the plugin lock, so no in-flight move is discarded.
|
|
181
|
+
await quarantine({ source, frozen, reason: 'Migration destination already exists; Layout B copy kept' });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
await renameEntry({ source, destination, frozen });
|
|
185
|
+
moved++;
|
|
186
|
+
emit('migration-moved', `${source} -> ${destination}`);
|
|
187
|
+
}
|
|
188
|
+
const oldPackages = path.join(workspaceRoot, 'packages', 'sha256');
|
|
189
|
+
for (const entry of await entries(oldPackages)) {
|
|
190
|
+
const source = path.join(oldPackages, entry.name);
|
|
191
|
+
try {
|
|
192
|
+
if (!DIGEST.test(entry.name) || !entry.isDirectory()) {
|
|
193
|
+
await quarantine({ source, frozen: entry.isDirectory(), reason: 'Unrecognized legacy package entry' });
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
let pluginId, paths;
|
|
197
|
+
try {
|
|
198
|
+
await contain({ root: workspaceRoot, entryPath: `packages/sha256/${entry.name}` });
|
|
199
|
+
const manifestPath = await contain({ root: source, entryPath: 'plugin.json' });
|
|
200
|
+
pluginId = parsePluginId({ value: JSON.parse(await fs.readFile(manifestPath, 'utf8')) });
|
|
201
|
+
paths = pluginStatePaths({ workspaceRoot, pluginId });
|
|
202
|
+
} catch (error) {
|
|
203
|
+
// Missing/invalid manifests are refused entries; filesystem failures remain resumable.
|
|
204
|
+
if (code(error) && !['ENOENT', 'EISDIR'].includes(code(error))) throw error;
|
|
205
|
+
await quarantine({ source, frozen: true, reason: String(error) });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
await withPluginStateLock({ filesystem: fs, contain, workspaceRoot, pluginId, withLock, run: () => move(source, path.join(paths.packages, entry.name), true) });
|
|
209
|
+
} catch (error) { complete = false; emit('migration-failed', `${entry.name}: ${String(error)}`); }
|
|
210
|
+
}
|
|
211
|
+
// Layout A memory and PLUGIN_DATA may exist without an installed package; migrate them too.
|
|
212
|
+
for (const bucket of ['memory', 'data']) {
|
|
213
|
+
for (const entry of await entries(path.join(workspaceRoot, bucket))) {
|
|
214
|
+
const source = path.join(workspaceRoot, bucket, entry.name);
|
|
215
|
+
try {
|
|
216
|
+
if (!entry.isDirectory()) {
|
|
217
|
+
await quarantine({ source, reason: 'Legacy state is not a directory' });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
let paths;
|
|
221
|
+
try { paths = pluginStatePaths({ workspaceRoot, pluginId: entry.name }); }
|
|
222
|
+
catch (error) { await quarantine({ source, reason: String(error) }); continue; }
|
|
223
|
+
await withPluginStateLock({ filesystem: fs, contain, workspaceRoot, pluginId: entry.name, withLock, run: () => move(source, bucket === 'data' ? paths.data : path.join(paths.root, 'memory')) });
|
|
224
|
+
} catch (error) { complete = false; emit('migration-failed', `${bucket}/${entry.name}: ${String(error)}`); }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const folder of await entries(workspaceRoot)) {
|
|
228
|
+
if (!folder.isDirectory() || !SAFE_ID.test(folder.name) || ['staging', 'packages', 'memory', 'data'].includes(folder.name)) continue;
|
|
229
|
+
try {
|
|
230
|
+
await withPluginStateLock({ filesystem: fs, contain, workspaceRoot, pluginId: folder.name, withLock, run: async () => {
|
|
231
|
+
const paths = pluginStatePaths({ workspaceRoot, pluginId: folder.name });
|
|
232
|
+
await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.relative(workspaceRoot, paths.packages) });
|
|
233
|
+
const digests = (await entries(paths.packages)).filter(digest => digest.isDirectory() && DIGEST.test(digest.name));
|
|
234
|
+
for (const digest of digests) {
|
|
235
|
+
const packageRoot = await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.relative(workspaceRoot, path.join(paths.packages, digest.name)) });
|
|
236
|
+
// Legacy install froze every package; a crash after rename can leave only its root writable.
|
|
237
|
+
await fs.chmod(packageRoot, 0o555);
|
|
238
|
+
}
|
|
239
|
+
// Wait until legacy memory/data moves have finished before creating destinations for them.
|
|
240
|
+
if (complete && digests.length) for (const directory of [paths.learned, paths.notes, paths.data]) {
|
|
241
|
+
await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.relative(workspaceRoot, directory) });
|
|
242
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
243
|
+
}
|
|
244
|
+
} });
|
|
245
|
+
} catch (error) { complete = false; emit('migration-failed', `${folder.name}: ${String(error)}`); }
|
|
246
|
+
}
|
|
247
|
+
if (complete) {
|
|
248
|
+
for (const directory of [oldPackages, path.dirname(oldPackages), path.join(workspaceRoot, 'memory'), path.join(workspaceRoot, 'data')]) {
|
|
249
|
+
try { await fs.rmdir(directory); } catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(code(error))) throw error; }
|
|
250
|
+
}
|
|
251
|
+
const temporary = `${marker}.${randomUUID()}.tmp`;
|
|
252
|
+
const handle = await fs.open(temporary, 'wx', 0o600);
|
|
253
|
+
try { await handle.writeFile(JSON.stringify({ layoutVersion: 2 }), 'utf8'); await handle.sync(); }
|
|
254
|
+
finally { await handle.close(); }
|
|
255
|
+
try { await fs.rename(temporary, marker); } finally { await fs.rm(temporary, { force: true }); }
|
|
256
|
+
emit('migration-complete', workspaceRoot);
|
|
257
|
+
}
|
|
258
|
+
return { complete, moved };
|
|
259
|
+
} });
|
|
260
|
+
}
|
|
@@ -1,153 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* @file Per-workspace Agent Plugin activation, separate from the host-plugin runtime's table.
|
|
3
|
+
* Package presence is consent only for operator installs. The seeder records bundled packages
|
|
4
|
+
* disabled before they may be spent; no-overwrite preserves an operator's decision across boots.
|
|
5
|
+
* No activation entry means active for operator-installed packages so upgrades do not revoke
|
|
6
|
+
* consent already given. Removing a bundled record recreates its disabled seed on the next boot.
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* Discovery may collapse unreadable/malformed records to empty so one corrupt byte cannot hide
|
|
9
|
+
* every installed plugin. Spending a capability (tool invocation or prompt injection) instead uses
|
|
10
|
+
* resolveAgentPluginActivation: no file/missing entry is active, but an unreadable file or malformed
|
|
11
|
+
* target entry is undetermined and denied. The caller records/logs that denial so it is visible.
|
|
12
|
+
* Writers never rewrite what they could not read: read the strict RAW bag, refuse unreadable files
|
|
13
|
+
* and malformed target entries, and preserve every unrelated entry, including malformed ones.
|
|
14
|
+
* Normalizing before a rewrite could erase a disabled/undetermined decision and silently reactivate
|
|
15
|
+
* its plugin. A corrupt activation file makes seeding fail rather than install around the fault.
|
|
14
16
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
17
|
+
* Filesystem state stays under layout.ts's tenant-isolated workspace root. Discovery, registration
|
|
18
|
+
* and injection already receive that layout; adding a database handle would add a repository and
|
|
19
|
+
* cross-process freshness dependency. The other plugin system has a distinct format and lifecycle.
|
|
18
20
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* Concurrency: serialize workspace writes in-process for FIFO order, then take the shared platform
|
|
22
|
+
* exclusive file lock before the strict read and hold it through publication. Exported functions
|
|
23
|
+
* must not re-enter a chain/lock callback: they would wait on authority they already hold.
|
|
24
|
+
* Private writeActivationDecision permits bundled-record writes without that reentrancy hazard.
|
|
25
|
+
* Unique temp files plus write/rename prevent partial reads; fsync before rename and best-effort
|
|
26
|
+
* directory fsync keep a power loss from publishing an empty activation file.
|
|
24
27
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* `{ enabled: false, origin: "bundled" }` record AT SEED TIME, before anything can read it — and
|
|
31
|
-
* because seeding is idempotent and re-runs on every boot, deleting `activations.json` re-creates
|
|
32
|
-
* the disabled record rather than promoting the bundled plugin to active. The unsafe direction is
|
|
33
|
-
* closed; the safe one (an operator's own install keeps working) is left open.
|
|
34
|
-
*
|
|
35
|
-
* The net rule, then: **presence implies consent only for packages an operator put there.** The
|
|
36
|
-
* record exists to say "the host put this here, and nobody has said yes yet."
|
|
37
|
-
*
|
|
38
|
-
* ---------------------------------------------------------------------------
|
|
39
|
-
* ...but "absent" only means active when the file itself was READ successfully
|
|
40
|
-
* ---------------------------------------------------------------------------
|
|
41
|
-
* {@link readAgentPluginActivations} collapses a missing file, an unreadable one, a malformed one,
|
|
42
|
-
* and one with the wrong envelope into the same empty record — fail-OPEN, for the reason its own
|
|
43
|
-
* doc gives. That is right for the two surfaces that only DISCOVER plugins (a corrupt byte must not
|
|
44
|
-
* silently hide every plugin an operator installed), and wrong for the one surface that decides
|
|
45
|
-
* whether a capability may be SPENT: there, "I could not read the operator's decisions" is not a
|
|
46
|
-
* statement that everything is permitted.
|
|
47
|
-
*
|
|
48
|
-
* {@link resolveAgentPluginActivation} is that stricter reader, added 2026-09-16 for
|
|
49
|
-
* `tool-registrations.ts`'s per-call tool gate. It separates the two things
|
|
50
|
-
* {@link readAgentPluginActivations} folds together:
|
|
51
|
-
*
|
|
52
|
-
* - **no file, or a well-formed file with no entry for this plugin** — nothing was recorded, so the
|
|
53
|
-
* "absent means active" rule above applies unchanged, and it answers `active`;
|
|
54
|
-
* - **a file that exists but could not be read, parsed, or shape-checked, or an entry for THIS
|
|
55
|
-
* plugin that fails normalization** — a decision may well have been recorded and cannot be read,
|
|
56
|
-
* so it answers `undetermined`, and the gate denies.
|
|
57
|
-
*
|
|
58
|
-
* The header's own objection to fail-CLOSED ("no error surface to notice it by") is answered at that
|
|
59
|
-
* call site and only there: a denied invocation is recorded as `denied` in the daemon's audit trail
|
|
60
|
-
* and the gate logs the reason, so a corrupt file is loud rather than silent. Nothing about what
|
|
61
|
-
* "active" MEANS differs between the two readers — only whether a fault is allowed to masquerade as
|
|
62
|
-
* an answer.
|
|
63
|
-
*
|
|
64
|
-
* ---------------------------------------------------------------------------
|
|
65
|
-
* Why a file and not a table
|
|
66
|
-
* ---------------------------------------------------------------------------
|
|
67
|
-
* All three places that must consult it — `capability-source.ts` (discovery),
|
|
68
|
-
* `tool-registrations.ts` (tool registration), `resolve-agent-plugin-refs.ts` (run-start injection)
|
|
69
|
-
* — receive an `AgentPluginWorkspaceLayout` or a `workspaceId`, and NONE of them has a database
|
|
70
|
-
* handle. Threading one through would mean giving three pure-filesystem call paths a repo
|
|
71
|
-
* dependency (and the agent daemon is a second OS process with its own handle over the same WAL —
|
|
72
|
-
* `agent-daemon-server.ts:298-299` — so the two would also have to agree about freshness).
|
|
73
|
-
*
|
|
74
|
-
* Everything else about an installed Agent Plugin already lives on disk under the workspace's own
|
|
75
|
-
* tenant-isolated root; its activation living beside it needs no new seam, no migration, and no
|
|
76
|
-
* cross-process cache-coherence argument. `plugin_activations` (`db/schema.sqlite.ts`) is the SPEC-005
|
|
77
|
-
* `.host-plugin` runtime's table for a different plugin system with a different lifecycle — reusing
|
|
78
|
-
* it would conflate two unrelated things that merely share a word.
|
|
79
|
-
*
|
|
80
|
-
* ---------------------------------------------------------------------------
|
|
81
|
-
* Writers never rewrite what they could not read (2026-09-16, t91 F1.1)
|
|
82
|
-
* ---------------------------------------------------------------------------
|
|
83
|
-
* Every writer below used to build its next state from {@link readAgentPluginActivations}'s LENIENT
|
|
84
|
-
* view — the same "unreadable folds to empty" read that is correct for discovery — and then wrote
|
|
85
|
-
* that view straight back out. Against a corrupt file, that laundered every operator decision inside
|
|
86
|
-
* it into a fresh, well-formed file recording NOTHING: a disabled plugin's `enabled: false` record
|
|
87
|
-
* simply vanished, and "absent means active" (this header, above) turned it active again. A second,
|
|
88
|
-
* narrower arm of the same bug survived even a whole-file fix: the writers built their next state
|
|
89
|
-
* from the NORMALIZED bag, which silently drops any single malformed entry — so toggling one
|
|
90
|
-
* unrelated plugin was enough to erase a neighboring entry the gate was correctly treating as
|
|
91
|
-
* `undetermined` (denied), re-admitting it as `active` the moment the rewrite landed.
|
|
92
|
-
*
|
|
93
|
-
* The rule now: every writer reads the RAW `plugins` bag with {@link readPluginsBagStrict} — which
|
|
94
|
-
* throws {@link AgentPluginActivationsUnreadableError} on an unreadable file instead of returning an
|
|
95
|
-
* empty one — and edits that raw bag directly, preserving byte-for-byte every entry it is not the
|
|
96
|
-
* one changing (malformed or not). `absent` (no file yet) still starts empty, so a first boot seeds
|
|
97
|
-
* exactly as before; only a file that EXISTS and cannot be read, parsed, or shape-checked refuses.
|
|
98
|
-
*
|
|
99
|
-
* The boot consequence is `seed-bundled.ts`'s to own (see that file's header): a corrupt file makes
|
|
100
|
-
* the seeder install nothing and report every bundled plugin `failed`, rather than write around the
|
|
101
|
-
* fault. Nothing here throws on the read `readAgentPluginActivations` itself still uses for
|
|
102
|
-
* discovery — that reader, and its "absent/unreadable both fold to empty" contract, are unchanged.
|
|
103
|
-
*
|
|
104
|
-
* ---------------------------------------------------------------------------
|
|
105
|
-
* Concurrency
|
|
106
|
-
* ---------------------------------------------------------------------------
|
|
107
|
-
* Writes are write-temp-then-`rename`, with a uniquely named temp file per write, so a reader never
|
|
108
|
-
* observes a half-written file.
|
|
109
|
-
*
|
|
110
|
-
* Within ONE process every read-modify-write of a workspace's file is serialized
|
|
111
|
-
* ({@link serializeActivationsWrite}). This used to be waved off as "a race nobody has, whose worst
|
|
112
|
-
* case is a visibly wrong checkbox". Both halves were false (t91 review, 2026-09-16): the admin
|
|
113
|
-
* Agent Plugins screen deliberately lets two rows toggle at once, and each row renders its own
|
|
114
|
-
* PATCH response — so when two writes both read the same file and the second `rename` erased the
|
|
115
|
-
* first one's `enabled: false`, the operator saw the plugin OFF while "absent means active" had
|
|
116
|
-
* already turned it back ON. Two writes in the same millisecond also shared one temp path, and one
|
|
117
|
-
* of them failed with `ENOENT`.
|
|
118
|
-
*
|
|
119
|
-
* ACROSS processes (t91, 2026-09-16, plan `agent-reports/2026-09-16-t91-plan-activations-lock.md`):
|
|
120
|
-
* every writer below also takes an exclusive, cross-process lock (`exclusive-file-lock.ts`) on
|
|
121
|
-
* `<root>/activations.json.lock` before its strict read, and holds it until the rename lands. The
|
|
122
|
-
* in-process chain above stays in FRONT of that lock — it still gives same-process writers FIFO
|
|
123
|
-
* order with no polling — and the lock is not reentrant, so the invariant is: **no exported function
|
|
124
|
-
* in this file may be called from inside a chain turn or a lock callback.** Such a call would wait on
|
|
125
|
-
* a lock (or a chain slot) it is already holding, and hang exactly like re-entering the chain would.
|
|
126
|
-
* `recordBundledAgentPluginIfAbsent` keeps using the private `writeActivationDecision` rather than
|
|
127
|
-
* calling `setAgentPluginActivation`, for this reason.
|
|
128
|
-
*
|
|
129
|
-
* A lock is judged stale — and broken, unblocking every waiter — if its holder is a dead process on
|
|
130
|
-
* this same host, or if it is simply older than 10s; see `exclusive-file-lock.ts`'s own header for
|
|
131
|
-
* the full protocol and every failure mode it closes. A writer that cannot take the lock within 15s,
|
|
132
|
-
* or that loses it (a stalled holder judged stale by someone else) right before its own rename,
|
|
133
|
-
* throws {@link AgentPluginActivationsBusyError} and writes NOTHING — never a partial or stale write.
|
|
134
|
-
*
|
|
135
|
-
* Residuals this does NOT close, stated rather than hidden:
|
|
136
|
-
* - R6: the boot seeder still installs a package before recording it inactive; a crash between the
|
|
137
|
-
* two leaves an unrecorded (and therefore active) bundled package. Pre-flighting the lock clears
|
|
138
|
-
* any stale lock before anything is installed, which narrows but does not remove this window.
|
|
139
|
-
* - The tiny gap between a holder's own `assertHeld()` and its `rename` — closed to "never a stale
|
|
140
|
-
* write survives it", not to "zero probability another process ever judged this lock stale in that
|
|
141
|
-
* instant".
|
|
142
|
-
* - A network filesystem, or a lock left by a crash that is later swept into site source control by
|
|
143
|
-
* `duplicate-site.ts`'s `cpSync` (this repo does not touch `sites/**` here to fix that).
|
|
144
|
-
* - A killed writer can leave a stray `activations.json.tmp-*` file; nothing currently sweeps those.
|
|
145
|
-
*
|
|
146
|
-
* Architectural role:
|
|
147
|
-
* Filesystem state for one workspace, reached only through `layout.ts`'s `forWorkspace()` root — the
|
|
148
|
-
* same tenant-isolation guarantee every other path in this feature goes through.
|
|
28
|
+
* Residual limits: installing before the inactive record is committed leaves a crash window; lock
|
|
29
|
+
* preflight narrows it but cannot remove it. The assertHeld/rename gap is not an atomic ownership
|
|
30
|
+
* check. Network filesystems and copied lock files need host handling; killed writers can leave
|
|
31
|
+
* unswept activations.json.tmp-* files. See the lock adapter below for stale/timeout budgets and
|
|
32
|
+
* authority checks.
|
|
149
33
|
*/
|
|
150
|
-
/**
|
|
34
|
+
/** Effects are supplied per host context.
|
|
151
35
|
* Kernel exclusive create chooses one writer. The shared platform lock binds ownership to
|
|
152
36
|
* a unique token and inode/device, verifies unchanged bytes/mtime before stale removal, and
|
|
153
37
|
* never treats a permission failure as proof of a dead pid. No heartbeat extends the explicit
|
|
@@ -155,7 +39,8 @@
|
|
|
155
39
|
* before publication. The shared fixed polling replaces local jitter; activation keeps its
|
|
156
40
|
* 15-second timeout, timed on the clock port's monotonic reading so a wall-clock jump can neither
|
|
157
41
|
* end nor stretch the wait (stale age stays on wall time, matching lock-file mtimes), and
|
|
158
|
-
* translates shared errors into busy.
|
|
42
|
+
* translates shared errors into busy. A stale/dead holder can lose authority; a writer that cannot
|
|
43
|
+
* acquire within 15 seconds or loses the lock before rename reports busy and publishes nothing.
|
|
159
44
|
*/
|
|
160
45
|
import path from "node:path";
|
|
161
46
|
import { FileLockLostError, FileLockTimeoutError } from "@jini-ai/platform/fs/file-lock";
|
|
@@ -271,7 +156,7 @@ function buildModule(ports) {
|
|
|
271
156
|
* @throws Nothing for a missing, unreadable, malformed, or wrong-version file: all of those return
|
|
272
157
|
* an empty record. That is fail-OPEN, and it is the correct direction here specifically because this
|
|
273
158
|
* reader is used for DISCOVERY only — nothing that SPENDS a capability or WRITES the file uses it
|
|
274
|
-
*
|
|
159
|
+
* — the per-call tool gate and run-start injection both use the
|
|
275
160
|
* fail-CLOSED {@link resolveAgentPluginActivation}, and every writer below reads the strict raw bag
|
|
276
161
|
* and refuses outright on an unreadable file (see this file's header, "Writers never rewrite what
|
|
277
162
|
* they could not read").
|
|
@@ -565,24 +450,11 @@ function buildModule(ports) {
|
|
|
565
450
|
return normalizePluginsBag(plugins);
|
|
566
451
|
}
|
|
567
452
|
/**
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
* the bare string `"bundled"`, not `{ origin: "bundled", ... }`) rewrote that entry as a well-formed
|
|
574
|
-
* `operator-installed` record via {@link isRecordedAsBundled}'s `false` fallback — which both defeats
|
|
575
|
-
* `uninstall.ts`'s bundled-refusal for malformed entries (`583d2f5e`) and re-seeds the plugin as
|
|
576
|
-
* bundled-and-inactive on the next boot, since {@link recordBundledAgentPluginIfAbsent}'s own
|
|
577
|
-
* `Object.hasOwn` no-overwrite check can no longer tell "an operator's real decision" apart from "a
|
|
578
|
-
* garbled record this write invented". Consistent with this file's "undetermined means refuse" rule
|
|
579
|
-
* (see the header and {@link resolveAgentPluginActivation}): an existing entry that cannot be read is
|
|
580
|
-
* left exactly as it is, never repaired by a guess. `recordBundledAgentPluginIfAbsent` never reaches
|
|
581
|
-
* this — it only calls {@link writeActivationDecision} when `Object.hasOwn(current, pluginId)` is
|
|
582
|
-
* already `false`.
|
|
583
|
-
*
|
|
584
|
-
* @throws {AgentPluginActivationsUnreadableError} `pluginId`'s own entry exists but does not
|
|
585
|
-
* normalize.
|
|
453
|
+
* Refuse a malformed target entry rather than guessing operator-installed origin. Guessing would
|
|
454
|
+
* defeat bundled uninstall refusal and make seeding confuse a fabricated record with consent.
|
|
455
|
+
* An undetermined entry stays intact. recordBundledAgentPluginIfAbsent reaches this writer only
|
|
456
|
+
* when the target key is genuinely absent, so it does not repair a malformed decision either.
|
|
457
|
+
* @throws {AgentPluginActivationsUnreadableError} The target entry exists but cannot normalize.
|
|
586
458
|
* @complexity O(1).
|
|
587
459
|
*/
|
|
588
460
|
function assertTargetEntryReadable(workspaceRoot, current, pluginId) {
|
|
@@ -676,7 +548,7 @@ function buildModule(ports) {
|
|
|
676
548
|
*
|
|
677
549
|
* Callers: `uninstall.ts`'s `uninstallAgentPlugin()`, on a successful uninstall (see that file's own
|
|
678
550
|
* header for the full decision), and `retire-bundled.ts`, for a retired bundled plugin's leftover
|
|
679
|
-
* record when no package of it is installed
|
|
551
|
+
* record when no package of it is installed.
|
|
680
552
|
* Short version: a tombstone (`enabled: false` left behind after the bytes are gone) would invert
|
|
681
553
|
* this file's own "absent means active, because an operator's own install IS the consent" rule
|
|
682
554
|
* (this file's header, above) the moment the SAME plugin id is ever reinstalled — the fresh install
|
|
@@ -779,7 +651,7 @@ function buildModule(ports) {
|
|
|
779
651
|
* read"), so this is the one place that wraps it back in the `{ schemaVersion: 1, plugins }`
|
|
780
652
|
* envelope for serialization.
|
|
781
653
|
*
|
|
782
|
-
* Durability
|
|
654
|
+
* Durability: the temp file is fsync'd before the rename, and the workspace
|
|
783
655
|
* directory is best-effort fsync'd after — see this file's header and `syncDirectoryBestEffort`'s
|
|
784
656
|
* own doc for why a power loss must never leave a 0-byte `activations.json` behind.
|
|
785
657
|
*
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import type { AgentPluginLifecyclePorts } from "./ports.js";
|
|
2
2
|
export declare const BUNDLED_DIGESTS_FILENAME = "bundled-digests.json";
|
|
3
|
+
/** Plugin id -> the archive digest this build seeded for it. Empty whenever nothing is recorded, or
|
|
4
|
+
* whenever the record cannot be read — see this file's header, consequence 3. */
|
|
3
5
|
export type BundledAgentPluginDigests = ReadonlyMap<string, string>;
|
|
6
|
+
/** One plugin's seed, as {@link recordBundledAgentPluginDigests} takes it — the pair `seed-
|
|
7
|
+
* bundled.ts` already holds after a successful install. */
|
|
4
8
|
export interface SeededBundledAgentPluginDigest {
|
|
5
9
|
readonly pluginId: string;
|
|
6
10
|
readonly archiveDigest: string;
|
|
7
11
|
}
|
|
12
|
+
/** The narrow slice of an `InstalledAgentPlugin` {@link preferBundledAgentPluginDigests} reads, so
|
|
13
|
+
* callers holding richer shapes keep their own types through it. */
|
|
8
14
|
export interface InstalledDigestIdentity {
|
|
9
15
|
readonly pluginId: string;
|
|
10
16
|
readonly archiveDigest: string;
|