@north-light/api 0.3.588 → 0.3.590

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.
Files changed (57) hide show
  1. package/dist/api/client.d.ts +4 -1
  2. package/dist/api/client.js +2 -1
  3. package/dist/api/contract.d.ts +1 -1
  4. package/dist/api/contract.js +1 -1
  5. package/dist/api/dto/docs.d.ts +5 -0
  6. package/dist/api/dto/settings.d.ts +21 -0
  7. package/dist/build-root.d.ts +2 -0
  8. package/dist/build-root.js +6 -1
  9. package/dist/commands/pkg/app-installs.d.ts +53 -0
  10. package/dist/commands/pkg/app-installs.js +125 -0
  11. package/dist/commands/pkg/market-inspect.js +4 -0
  12. package/dist/commands/pkg/market-manage.js +6 -0
  13. package/dist/commands/pkg/plugin-inspect.js +6 -2
  14. package/dist/commands/pkg/plugin-manage.js +15 -0
  15. package/dist/core/canvas/schema-version.d.ts +1 -1
  16. package/dist/core/canvas/schema-version.js +1 -1
  17. package/dist/core/config.d.ts +1 -0
  18. package/dist/core/config.js +10 -0
  19. package/dist/core/graph/names.js +7 -6
  20. package/dist/core/graph/package-docs.d.ts +6 -15
  21. package/dist/core/graph/package-docs.js +33 -83
  22. package/dist/core/graph/package-publish.d.ts +60 -0
  23. package/dist/core/graph/package-publish.js +158 -0
  24. package/dist/core/graph/package-reconcile.d.ts +20 -1
  25. package/dist/core/graph/package-reconcile.js +83 -15
  26. package/dist/core/import/export/dump.js +12 -36
  27. package/dist/core/installed-plugins.js +5 -7
  28. package/dist/core/packages/eligibility.d.ts +16 -0
  29. package/dist/core/packages/eligibility.js +34 -0
  30. package/dist/core/packages/launch-kinds.d.ts +6 -0
  31. package/dist/core/packages/launch-kinds.js +20 -0
  32. package/dist/core/packages/source-url.d.ts +3 -0
  33. package/dist/core/packages/source-url.js +17 -0
  34. package/dist/core/permissions/ceiling.d.ts +9 -2
  35. package/dist/core/permissions/ceiling.js +23 -9
  36. package/dist/core/permissions/evaluator.js +1 -1
  37. package/dist/core/providers/bundles.js +2 -3
  38. package/dist/core/runtime/launch-target.d.ts +6 -2
  39. package/dist/core/runtime/launch-target.js +8 -4
  40. package/dist/core/runtime/launch.d.ts +8 -1
  41. package/dist/core/runtime/launch.js +13 -6
  42. package/dist/core/runtime/runner/types.d.ts +7 -1
  43. package/dist/core/runtime/spawn.js +5 -5
  44. package/dist/core/runtime/view-socket.d.ts +2 -1
  45. package/dist/core/runtime/view-socket.js +3 -2
  46. package/dist/core/runtime/warm-pool.js +1 -0
  47. package/dist/core/scoped-state/legacy-profiles.js +1 -1
  48. package/dist/core/scoped-state/person-rows.js +38 -35
  49. package/dist/core/scoped-state/person-store.d.ts +23 -0
  50. package/dist/core/scoped-state/person-store.js +5 -1
  51. package/dist/daemon/reconcilers/node-lifecycle/remote-wake.d.ts +2 -1
  52. package/dist/daemon/reconcilers/node-lifecycle/remote-wake.js +3 -2
  53. package/package.json +1 -1
  54. package/dist/core/graph/package-kinds.d.ts +0 -22
  55. package/dist/core/graph/package-kinds.js +0 -49
  56. package/dist/core/scoped-state/materialized-plugins.d.ts +0 -20
  57. package/dist/core/scoped-state/materialized-plugins.js +0 -105
@@ -5,102 +5,44 @@
5
5
  * A plugin is an app: its documents belong to the app `<plugin>` (identity `app:<plugin>`,
6
6
  * the one `ownerForGrantee` gives), and a document with a `source_path` is shipped. */
7
7
  import { randomUUID } from 'node:crypto';
8
- import { CrtrError } from '../errors.js';
9
- import { emitEvent } from '../events/emit.js';
10
- import { quarantineBrokenPlugins } from '../plugin-quarantine.js';
11
- import { listInstalledPlugins } from '../resolver.js';
12
- import { builtinMemoryRoot, pluginMemoryDir } from '../scope.js';
13
8
  import { documentFields } from '../../migrations/004-canvas-documents/fields.js';
14
- import { put as putBody } from './bodies.js';
9
+ import { get as getBody, put as putBody } from './bodies.js';
15
10
  import { applyDocumentFields, deleteDocument, documentRecord, heads, importDocument, linksIn, moveDocument } from './documents.js';
16
11
  import { deleteShippedReader, writeShippedReader } from '../permissions/facts.js';
17
12
  import { setLinks } from './edges.js';
18
13
  import { publish } from './events.js';
19
- import { resolveName } from './names.js';
20
- import { packageFiles } from './package-files.js';
21
- import { ensureOwner, getById, setObjectKind } from './objects.js';
22
- import { checkoutsForCwd } from './repo-sync/repos.js';
14
+ import { getById, setObjectKind } from './objects.js';
23
15
  import { rulesOf } from './rules.js';
24
- /** The builtin corpus (app `crtr`), then every enabled user-scope plugin's `memory/` folder. */
25
- export function packageSources() {
26
- return [
27
- { plugin: 'crtr', dir: builtinMemoryRoot() },
28
- ...listInstalledPlugins('user').filter((p) => p.enabled).map((p) => ({ plugin: p.name, dir: pluginMemoryDir(p) })),
29
- ];
30
- }
31
- /** Converge every package's documents onto its files. Run inside the caller's transaction.
32
- * Idempotent: a second run over unchanged files changes no document, revision or rule (it re-derives
33
- * each document's link edges, which land the same). A changed body replaces the
34
- * document's single revision and publishes `edited`; a vanished file (or plugin) marks its
35
- * document deleted and publishes `deleted`. */
36
- export async function reconcilePackageDocuments(db, explicitSources) {
37
- const report = { created: 0, updated: 0, deleted: 0, conflicts: [], invalid: [], quarantined: [] };
38
- // One damaged plugin directory (a 0-byte manifest from an interrupted write) is set aside, never fatal.
39
- // Only the real installed set is swept; a caller passing its own sources has no plugins directory here.
40
- if (explicitSources === undefined) {
41
- report.quarantined = quarantineBrokenPlugins();
42
- for (const q of report.quarantined) {
43
- emitEvent({ level: 'error', event: 'daemon.plugins.quarantined', fields: { plugin: q.name, file: q.file, reason: q.reason, ...(q.movedTo !== undefined ? { moved_to: q.movedTo } : {}) } });
44
- }
45
- }
46
- const sources = explicitSources ?? packageSources();
47
- const reposFn = async (cwd) => await checkoutsForCwd(db, cwd);
48
- const reconciled = new Set();
49
- const converged = [];
50
- const names = new Set();
51
- for (const source of sources) {
52
- let owner;
53
- try {
54
- if (names.has(source.plugin))
55
- throw new CrtrError('conflict', `plugin "${source.plugin}" is already provided by another package`);
56
- names.add(source.plugin);
57
- owner = await ensureOwner(db, 'app', 'app', `app:${source.plugin}`, source.plugin);
58
- }
59
- catch (error) {
60
- if (!(error instanceof CrtrError) || error.code !== 'conflict')
61
- throw error;
62
- report.conflicts.push({ plugin: source.plugin, message: error.message });
63
- continue;
64
- }
65
- reconciled.add(owner.id);
66
- const { files, invalid } = packageFiles(source.dir);
67
- report.invalid.push(...invalid);
68
- const seen = new Set();
69
- for (const file of files) {
70
- const id = await convergeFile(db, owner, file, report, reposFn);
71
- seen.add(id);
72
- converged.push({ owner, id, body: file.body });
73
- }
74
- await deleteMissing(db, owner, seen, report, reposFn);
16
+ /** The live shipped document `name` (`<name>`, `<app>/<name>` or `app:<app>/<name>`) names for a package published by `owner`: the package's own documents
17
+ * first, then every other app's, `crtr` last. It looks across all published packages, whoever installed them: whether a reader may follow the edge is the
18
+ * reader's permission check when the link is rendered, not a property of the publisher. */
19
+ async function shippedNamed(db, owner, name) {
20
+ const typed = /^(?:app:)?([^/:]+)\/(.+)$/.exec(name);
21
+ const candidates = [{ app: null, doc: name }];
22
+ if (typed !== null)
23
+ candidates.push({ app: typed[1], doc: typed[2] });
24
+ for (const { app, doc } of candidates) {
25
+ const row = await db.prepare(`SELECT o.id FROM objects o JOIN documents d ON d.object = o.id AND d.source_path IS NOT NULL JOIN objects a ON a.id = o.owner AND a.type = 'app'
26
+ WHERE o.type = 'document' AND o.deleted_at IS NULL AND o.home_machine IS NULL AND o.name = ? AND (?::text IS NULL OR a.name = ?)
27
+ ORDER BY (o.owner = ?) DESC, a.name = 'crtr', a.name LIMIT 1`).get(doc, app, app, owner.id);
28
+ if (row !== undefined)
29
+ return await getById(db, row.id) ?? undefined;
75
30
  }
76
- // A plugin no longer installed (or disabled) keeps its app and the app's other objects;
77
- // only its shipped documents are deleted.
78
- const stale = await db.prepare(`SELECT DISTINCT o.owner AS id FROM objects o JOIN documents d ON d.object = o.id
79
- WHERE o.type = 'document' AND o.deleted_at IS NULL AND d.source_path IS NOT NULL`).all();
80
- for (const { id } of stale) {
81
- if (!reconciled.has(id))
82
- await deleteMissing(db, (await getById(db, id)), new Set(), report, reposFn);
83
- }
84
- // Links resolve only after every package's documents are in place, so a link to a document
85
- // another package ships, or one reconciled later in this pass, still attaches.
86
- for (const { owner, id, body } of converged)
87
- await deriveLinks(db, owner, (await getById(db, id)), body);
88
- return report;
31
+ return undefined;
89
32
  }
90
33
  /** Record a package document's `links-to` edges the way a runtime document gets them, so delivery
91
- * attaches the link previews. Each `[[name]]` resolves in its own app's view and links only when it
92
- * names another shipped document (builtin or plugin): a package never links to a user's or a
34
+ * attaches the link previews. Each `[[name]]` links only when it names a shipped document of any
35
+ * published package (builtin, provider or plugin): a package never links to a user's or a
93
36
  * runtime document that happens to share a bare name. A name that resolves to nothing stays text.
94
37
  * Edges are rewritten only when they differ, so an unchanged pass writes nothing. */
95
38
  export async function deriveLinks(db, owner, doc, body) {
96
39
  if (doc.deleted_at !== null)
97
40
  return;
98
- const reader = { class: 'app', app: owner, scopes: [] };
99
41
  const links = [];
100
42
  for (const name of linksIn(body)) {
101
- const hit = await resolveName(db, reader, name);
102
- if ('object' in hit && hit.object.id !== doc.id && await shippedDocument(db, hit.object.id))
103
- links.push({ written_as: name, dst: hit.object });
43
+ const hit = await shippedNamed(db, owner, name);
44
+ if (hit !== undefined && hit.id !== doc.id)
45
+ links.push({ written_as: name, dst: hit });
104
46
  }
105
47
  const key = (rows) => JSON.stringify(rows.map((r) => [r.dst, r.written_as]).sort());
106
48
  const current = await db.prepare(`SELECT dst, written_as FROM edges WHERE type = 'links-to' AND src = ?`)
@@ -109,9 +51,17 @@ export async function deriveLinks(db, owner, doc, body) {
109
51
  return;
110
52
  await setLinks(db, doc, links);
111
53
  }
112
- /** Whether the object is a live shipped document (its `documents.source_path` is set). */
113
- async function shippedDocument(db, id) {
114
- return await db.prepare('SELECT 1 FROM documents WHERE object = ? AND source_path IS NOT NULL').get(id) !== undefined;
54
+ /** Re-derive the links of every live shipped document of every app. A package published before the one its documents name has no edge to it yet;
55
+ * publishing the missing one calls this, so the edges do not depend on which package arrived first. */
56
+ export async function relinkAllShipped(db) {
57
+ const docs = await db.prepare(`SELECT o.id, o.owner FROM objects o JOIN documents d ON d.object = o.id
58
+ WHERE o.type = 'document' AND o.deleted_at IS NULL AND d.source_path IS NOT NULL ORDER BY o.id`).all();
59
+ for (const { id, owner } of docs) {
60
+ const doc = (await getById(db, id));
61
+ const [head] = await heads(db, doc);
62
+ if (head !== undefined)
63
+ await deriveLinks(db, (await getById(db, owner)), doc, await getBody(db, head.body_hash));
64
+ }
115
65
  }
116
66
  export async function convergeFile(db, owner, file, report, reposFn) {
117
67
  const f = documentFields(file.frontmatter, file.body, { missingPrivacy: 'public' });
@@ -0,0 +1,60 @@
1
+ import type { KindConfig, ObjectKindDeclaration } from '../../types.js';
2
+ import { type PackageReconcileReport } from './package-docs.js';
3
+ import { type PackageFile } from './package-files.js';
4
+ import type { CanvasDb } from '../canvas/db.js';
5
+ /** What one app's package folder holds right now, read before the publishing transaction opens. */
6
+ export interface PackageSnapshot {
7
+ app: string;
8
+ origin: 'builtin' | 'provider' | 'plugin';
9
+ /** crtr release (builtin), bundle generation (provider), manifest version (plugin) */
10
+ version: string;
11
+ /** the directory's revision for a directory package; null for builtin and for a Mac's local package */
12
+ revision: number | null;
13
+ /** normalized marketplace URL of a plugin package; null otherwise */
14
+ source: string | null;
15
+ /** the manifest's launch `kinds` (persona kinds); null when it declares none */
16
+ launchKinds: Record<string, Partial<KindConfig>> | null;
17
+ /** the manifest's custom-object kinds, keyed by kind name; null when it declares none */
18
+ objectKinds: Record<string, ObjectKindDeclaration> | null;
19
+ digest: string;
20
+ /** files with `path` already package-relative, prefixed by the app: `<app>/<relative path>` */
21
+ files: PackageFile[];
22
+ invalid: Array<{
23
+ path: string;
24
+ error: string;
25
+ }>;
26
+ }
27
+ export interface PublishReport extends PackageReconcileReport {
28
+ /** the row's release is newer than this snapshot: nothing was written */
29
+ superseded: boolean;
30
+ /** what declaring the snapshot's object kinds did; empty when the bytes were already published */
31
+ kinds: {
32
+ applied: Array<{
33
+ kind: string;
34
+ outcome: 'inserted' | 'updated' | 'unchanged';
35
+ }>;
36
+ refused: Array<{
37
+ kind: string;
38
+ message: string;
39
+ }>;
40
+ };
41
+ }
42
+ /** The package folder `dir` as a snapshot of app `app`. Throws when the folder or any directory in it cannot be read. The digest covers the
43
+ * path and raw bytes of every markdown file, invalid ones included, so removing or changing an invalid file changes it. */
44
+ export declare function readPackageSnapshot(dir: string, meta: Pick<PackageSnapshot, 'app' | 'origin' | 'version'> & Partial<Pick<PackageSnapshot, 'revision' | 'source' | 'launchKinds' | 'objectKinds'>>): PackageSnapshot;
45
+ /** JSON with every object's keys sorted at every depth. */
46
+ export declare function canonicalJson(value: unknown): string;
47
+ /** App `crtr` as this process's release ships it. */
48
+ export declare function readBuiltinSnapshot(): PackageSnapshot;
49
+ /** Publish one snapshot inside the caller's write transaction. Serialized per app on the publication row. A `capture` is called after the lock is
50
+ * taken, so a Mac reads its disk with no older read able to commit after it. Returns what changed. */
51
+ export declare function publish(db: CanvasDb, input: PackageSnapshot | {
52
+ app: string;
53
+ origin: PackageSnapshot['origin'];
54
+ capture: () => PackageSnapshot;
55
+ }, publishedBy: string): Promise<PublishReport>;
56
+ /** A package folder that does not exist ships nothing: a plugin without a `memory/` folder. */
57
+ export declare function emptyPackageSnapshot(meta: Pick<PackageSnapshot, 'app' | 'origin' | 'version'> & Partial<Pick<PackageSnapshot, 'launchKinds' | 'objectKinds'>>): PackageSnapshot;
58
+ /** Take a package down: soft-delete the app's shipped documents, undeclare its manifest kinds, mark the row withdrawn. Nothing else of the
59
+ * app (its ordinary documents, its kind rows) is touched. The caller decides the package is no longer wanted. */
60
+ export declare function withdraw(db: CanvasDb, app: string): Promise<PublishReport>;
@@ -0,0 +1,158 @@
1
+ /** The package publisher, for the one package a release owns: app `crtr`, from `builtinMemoryRoot()`. A cloud role publishes it at every
2
+ * daemon start and touches nothing else: no other app's documents, no manifest kind, no `crtr_app_` token, no plugin directory.
3
+ * One `package_publications` row per published app is the lock a publisher takes and the release it compares against, so two pods
4
+ * of different releases (a rolling deploy) never leave older text over newer text. The Mac pass (`reconcilePackages`) publishes through the same function. */
5
+ import { createHash } from 'node:crypto';
6
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
7
+ import { join, relative, sep } from 'node:path';
8
+ import { compareReleaseVersions } from '../../api/runtime-version.js';
9
+ import { upsertKind } from './custom.js';
10
+ import { crtrVersion } from '../../shared/crtr-version.js';
11
+ import { emitEvent } from '../events/emit.js';
12
+ import { builtinMemoryRoot } from '../scope.js';
13
+ import { deleteMissing, convergeFile, relinkAllShipped } from './package-docs.js';
14
+ import { packageFiles } from './package-files.js';
15
+ import { ensureOwner } from './objects.js';
16
+ import { checkoutsForCwd } from './repo-sync/repos.js';
17
+ /** Every markdown file the package reader considers, as sorted `[package-relative path, raw bytes]`. Unlike `walkFiles`, a directory that
18
+ * cannot be read throws: a partial or empty read must never look like a package that dropped its documents. */
19
+ function strictPackageBytes(dir) {
20
+ if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory())
21
+ throw new Error(`package folder is missing or not a directory: ${dir}`);
22
+ const out = [];
23
+ const stack = [dir];
24
+ while (stack.length > 0) {
25
+ const current = stack.pop();
26
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
27
+ const full = join(current, entry.name);
28
+ if (entry.isDirectory()) {
29
+ if (!entry.name.startsWith('.'))
30
+ stack.push(full);
31
+ }
32
+ else if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'SKILL.md')
33
+ out.push([relative(dir, full).split(sep).join('/'), readFileSync(full)]);
34
+ }
35
+ }
36
+ return out.sort((a, b) => a[0] < b[0] ? -1 : 1);
37
+ }
38
+ /** The package folder `dir` as a snapshot of app `app`. Throws when the folder or any directory in it cannot be read. The digest covers the
39
+ * path and raw bytes of every markdown file, invalid ones included, so removing or changing an invalid file changes it. */
40
+ export function readPackageSnapshot(dir, meta) {
41
+ const bytes = strictPackageBytes(dir);
42
+ const { files, invalid } = packageFiles(dir);
43
+ const shipped = files.map((f) => ({ ...f, path: `${meta.app}/${relative(dir, f.path).split(sep).join('/')}` }));
44
+ const hash = createHash('sha256');
45
+ for (const [rel, data] of bytes)
46
+ hash.update(rel).update('\0').update(data).update('\0');
47
+ const shippedInvalid = invalid.map((i) => ({ ...i, path: `${meta.app}/${relative(dir, i.path).split(sep).join('/')}` }));
48
+ return snapshotOf({ revision: null, source: null, launchKinds: null, objectKinds: null, ...meta }, shipped, shippedInvalid, hash);
49
+ }
50
+ /** JSON with every object's keys sorted at every depth. */
51
+ export function canonicalJson(value) {
52
+ if (Array.isArray(value))
53
+ return `[${value.map(canonicalJson).join(',')}]`;
54
+ if (value !== null && typeof value === 'object') {
55
+ const record = value;
56
+ return `{${Object.keys(record).sort().filter((k) => record[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonicalJson(record[k])}`).join(',')}}`;
57
+ }
58
+ return JSON.stringify(value) ?? 'null';
59
+ }
60
+ /** Close a digest over the package's kinds and build the snapshot. */
61
+ function snapshotOf(meta, files, invalid, hash) {
62
+ hash.update(canonicalJson({ launchKinds: meta.launchKinds, objectKinds: meta.objectKinds, source: meta.source }));
63
+ return { ...meta, digest: hash.digest('hex'), files, invalid };
64
+ }
65
+ /** App `crtr` as this process's release ships it. */
66
+ export function readBuiltinSnapshot() {
67
+ return readPackageSnapshot(builtinMemoryRoot(), { app: 'crtr', origin: 'builtin', version: crtrVersion() });
68
+ }
69
+ /** Whether `snapshot` is older than the published row: a builtin by release, a directory package by its revision. A Mac's local package
70
+ * has no order of its own; it is captured under the row lock, so the last commit carries the newest bytes. */
71
+ function olderThanPublished(snapshot, row) {
72
+ if (snapshot.revision !== null)
73
+ return row.revision !== null && snapshot.revision < Number(row.revision);
74
+ if (snapshot.origin === 'builtin')
75
+ return compareReleaseVersions(snapshot.version, row.version) < 0;
76
+ return false;
77
+ }
78
+ /** Publish one snapshot inside the caller's write transaction. Serialized per app on the publication row. A `capture` is called after the lock is
79
+ * taken, so a Mac reads its disk with no older read able to commit after it. Returns what changed. */
80
+ export async function publish(db, input, publishedBy) {
81
+ const { app, origin } = input;
82
+ const owner = await ensureOwner(db, 'app', 'app', `app:${app}`, app);
83
+ const now = new Date().toISOString();
84
+ await db.prepare(`INSERT INTO package_publications (app, origin, version, digest, published_at, published_by)
85
+ VALUES (?, ?, '', '', ?, ?) ON CONFLICT (app) DO NOTHING`).run(owner.id, origin, now, publishedBy);
86
+ const row = await db.prepare('SELECT version, digest, revision, withdrawn_at FROM package_publications WHERE app = ? FOR UPDATE').get(owner.id);
87
+ const snapshot = 'capture' in input ? input.capture() : input;
88
+ const report = { created: 0, updated: 0, deleted: 0, conflicts: [], invalid: [...snapshot.invalid], quarantined: [], superseded: false, kinds: { applied: [], refused: [] } };
89
+ if (olderThanPublished(snapshot, row)) {
90
+ report.superseded = true;
91
+ emitEvent({ level: 'warn', event: 'daemon.packages.superseded', fields: { app: snapshot.app, snapshot_version: snapshot.version, published_version: row.version, snapshot_revision: snapshot.revision ?? 'none', published_revision: row.revision === null ? 'none' : Number(row.revision) } });
92
+ return report;
93
+ }
94
+ const sameBytes = row.digest === snapshot.digest && row.withdrawn_at === null;
95
+ if (!sameBytes) {
96
+ const reposFn = async (cwd) => await checkoutsForCwd(db, cwd);
97
+ const seen = new Set();
98
+ for (const file of snapshot.files)
99
+ seen.add(await convergeFile(db, owner, file, report, reposFn));
100
+ // An invalid file keeps its prior document: its name is not known, so a snapshot with any invalid file removes nothing.
101
+ if (snapshot.invalid.length === 0)
102
+ await deleteMissing(db, owner, seen, report, reposFn);
103
+ report.kinds = await convergeKinds(db, owner, snapshot.objectKinds);
104
+ // A package published before the one its documents name has no edge to it yet, so any change revisits every shipped document.
105
+ if (report.created + report.updated + report.deleted > 0)
106
+ await relinkAllShipped(db);
107
+ }
108
+ else if (row.version === snapshot.version && String(row.revision ?? '') === String(snapshot.revision ?? '')) {
109
+ return report;
110
+ }
111
+ await db.prepare(`UPDATE package_publications SET origin = ?, version = ?, revision = ?, source = ?, launch_kinds_json = ?, digest = ?, published_at = ?, published_by = ?, withdrawn_at = NULL
112
+ WHERE app = ?`).run(snapshot.origin, snapshot.version, snapshot.revision, snapshot.source, snapshot.launchKinds === null ? null : canonicalJson(snapshot.launchKinds), snapshot.digest, now, publishedBy, owner.id);
113
+ return report;
114
+ }
115
+ /** A package folder that does not exist ships nothing: a plugin without a `memory/` folder. */
116
+ export function emptyPackageSnapshot(meta) {
117
+ return snapshotOf({ revision: null, source: null, launchKinds: null, objectKinds: null, ...meta }, [], [], createHash('sha256'));
118
+ }
119
+ /** Declare the snapshot's object kinds under the app and undeclare the app's manifest kinds it no longer lists. A kind another owner holds is
120
+ * left alone (its document text still publishes). A kind row is never deleted. */
121
+ async function convergeKinds(db, owner, kinds) {
122
+ const report = { applied: [], refused: [] };
123
+ const declared = new Set();
124
+ for (const [name, decl] of Object.entries(kinds ?? {})) {
125
+ try {
126
+ const result = await upsertKind(db, { name, owner: owner, description: decl.description, events: decl.events,
127
+ read: decl.read === undefined ? null : { command: decl.read.command }, source: 'manifest' });
128
+ if (result.outcome !== 'skipped') {
129
+ declared.add(name);
130
+ report.applied.push({ kind: name, outcome: result.outcome });
131
+ }
132
+ }
133
+ catch (error) {
134
+ if (error.code !== 'kind_owned')
135
+ throw error;
136
+ report.refused.push({ kind: name, message: error.message });
137
+ emitEvent({ level: 'warn', event: 'daemon.packages.kind_owned', fields: { app: owner.id, kind: name } });
138
+ }
139
+ }
140
+ const rows = await db.prepare("SELECT name FROM object_kinds WHERE owner = ? AND source = 'manifest' AND declared = 1").all(owner.id);
141
+ for (const { name } of rows)
142
+ if (!declared.has(name))
143
+ await db.prepare('UPDATE object_kinds SET declared = 0 WHERE name = ?').run(name);
144
+ return report;
145
+ }
146
+ /** Take a package down: soft-delete the app's shipped documents, undeclare its manifest kinds, mark the row withdrawn. Nothing else of the
147
+ * app (its ordinary documents, its kind rows) is touched. The caller decides the package is no longer wanted. */
148
+ export async function withdraw(db, app) {
149
+ const report = { created: 0, updated: 0, deleted: 0, conflicts: [], invalid: [], quarantined: [], superseded: false, kinds: { applied: [], refused: [] } };
150
+ const owner = await ensureOwner(db, 'app', 'app', `app:${app}`, app);
151
+ const row = await db.prepare('SELECT withdrawn_at FROM package_publications WHERE app = ? FOR UPDATE').get(owner.id);
152
+ if (row === undefined || row.withdrawn_at !== null)
153
+ return report;
154
+ await deleteMissing(db, owner, new Set(), report, async (cwd) => await checkoutsForCwd(db, cwd));
155
+ await convergeKinds(db, owner, null);
156
+ await db.prepare("UPDATE package_publications SET withdrawn_at = ?, digest = '' WHERE app = ?").run(new Date().toISOString(), owner.id);
157
+ return report;
158
+ }
@@ -1,4 +1,3 @@
1
- import { type KindsReport } from './package-kinds.js';
2
1
  import { type PackageReconcileReport } from './package-docs.js';
3
2
  import type { CanvasDb } from '../canvas/db.js';
4
3
  export interface TokensReport {
@@ -10,6 +9,26 @@ export interface TokensReport {
10
9
  /** plugins whose `crtr_app_` token was removed because they no longer qualify */
11
10
  revoked: string[];
12
11
  }
12
+ export interface KindsReport {
13
+ /** every kind a valid plugin declares that now has its row, and what upserting it did */
14
+ applied: Array<{
15
+ plugin: string;
16
+ kind: string;
17
+ outcome: 'inserted' | 'updated' | 'unchanged';
18
+ }>;
19
+ /** kinds another owner holds; the plugin's other kinds still applied */
20
+ refused: Array<{
21
+ plugin: string;
22
+ kind: string;
23
+ code: 'kind_owned';
24
+ message: string;
25
+ }>;
26
+ /** enabled plugins whose manifest or commands are invalid (no kind update, no token); a provider bundle's kinds reconcile without a token, and plugins whose app handle another owner holds */
27
+ invalid: Array<{
28
+ plugin: string;
29
+ error: string;
30
+ }>;
31
+ }
13
32
  export interface PackagesReport extends PackageReconcileReport {
14
33
  kinds: KindsReport;
15
34
  tokens: TokensReport;
@@ -1,31 +1,99 @@
1
- /** The one package reconcile pass: documents, then kinds, then plugin app tokens. Runs in the caller's write
2
- * transaction. The token writes go to the secrets store and are idempotent, so a rolled-back pass re-converges
3
- * on the next one. */
4
- import { appPlugins } from '../plugin-app.js';
1
+ /** The Mac's package pass: the builtin corpus and every enabled local plugin or provider bundle are published through the publisher (each package
2
+ * captured from disk under its publication row's lock), a package whose app is no longer enabled is withdrawn, then plugin app tokens are
3
+ * minted and revoked. Runs in the caller's write transaction. The token writes go to the secrets store and are idempotent, so a rolled-back
4
+ * pass re-converges on the next one. A cloud role never runs this: it sees no person's plugins (it publishes `crtr` and the directory's packages). */
5
+ import { statSync } from 'node:fs';
6
+ import { CrtrError } from '../errors.js';
7
+ import { emitEvent } from '../events/emit.js';
8
+ import { invalidAppPlugins, appPlugins, kindPlugins } from '../plugin-app.js';
9
+ import { quarantineBrokenPlugins } from '../plugin-quarantine.js';
10
+ import { listInstalledPlugins } from '../resolver.js';
11
+ import { pluginMemoryDir } from '../scope.js';
5
12
  import { ensurePluginAppToken, listScopedApiTokens, revokePluginAppTokens } from '../secrets.js';
6
- import { reconcilePackageKinds } from './package-kinds.js';
7
- import { reconcilePackageDocuments } from './package-docs.js';
13
+ import { deleteMissing } from './package-docs.js';
14
+ import { emptyPackageSnapshot, publish, readBuiltinSnapshot, readPackageSnapshot, withdraw } from './package-publish.js';
15
+ import { getById } from './objects.js';
16
+ import { checkoutsForCwd } from './repo-sync/repos.js';
17
+ const PUBLISHED_BY = 'mac';
18
+ function isDirectory(path) {
19
+ return statSync(path, { throwIfNoEntry: false })?.isDirectory() === true;
20
+ }
8
21
  export async function reconcilePackages(db) {
9
- const documents = await reconcilePackageDocuments(db);
10
- const kinds = await reconcilePackageKinds(db);
11
- const tokens = { granted: [], revoked: [] };
22
+ const report = {
23
+ created: 0, updated: 0, deleted: 0, conflicts: [], invalid: [], quarantined: quarantineBrokenPlugins(),
24
+ kinds: { applied: [], refused: [], invalid: invalidAppPlugins().map((i) => ({ plugin: i.plugin.name, error: i.error })) },
25
+ tokens: { granted: [], revoked: [] },
26
+ };
27
+ for (const q of report.quarantined) {
28
+ emitEvent({ level: 'error', event: 'daemon.plugins.quarantined', fields: { plugin: q.name, file: q.file, reason: q.reason, ...(q.movedTo !== undefined ? { moved_to: q.movedTo } : {}) } });
29
+ }
30
+ const validKinds = new Set(kindPlugins().map((p) => p.name));
31
+ const published = new Set(['crtr']);
32
+ const add = (r, plugin) => {
33
+ report.created += r.created;
34
+ report.updated += r.updated;
35
+ report.deleted += r.deleted;
36
+ report.invalid.push(...r.invalid);
37
+ for (const k of r.kinds.applied)
38
+ report.kinds.applied.push({ plugin, kind: k.kind, outcome: k.outcome });
39
+ for (const k of r.kinds.refused)
40
+ report.kinds.refused.push({ plugin, kind: k.kind, code: 'kind_owned', message: k.message });
41
+ };
42
+ add(await publish(db, { app: 'crtr', origin: 'builtin', capture: () => readBuiltinSnapshot() }, PUBLISHED_BY), 'crtr');
43
+ for (const plugin of listInstalledPlugins('user').filter((p) => p.enabled)) {
44
+ const origin = plugin.manifest.provider === undefined ? 'plugin' : 'provider';
45
+ const dir = pluginMemoryDir(plugin);
46
+ const kinds = plugin.manifest.kinds !== undefined && Object.keys(plugin.manifest.kinds).length > 0 ? plugin.manifest.kinds : null;
47
+ const meta = { app: plugin.name, origin, version: plugin.manifest.version ?? '', launchKinds: kinds, objectKinds: validKinds.has(plugin.name) ? plugin.manifest.object_kinds ?? null : null };
48
+ try {
49
+ if (published.has(plugin.name))
50
+ throw new CrtrError('conflict', `plugin "${plugin.name}" is already provided by another package`);
51
+ published.add(plugin.name);
52
+ add(await publish(db, { app: plugin.name, origin, capture: () => isDirectory(dir) ? readPackageSnapshot(dir, meta) : emptyPackageSnapshot(meta) }, PUBLISHED_BY), plugin.name);
53
+ }
54
+ catch (error) {
55
+ if (!(error instanceof CrtrError) || error.code !== 'conflict')
56
+ throw error;
57
+ report.conflicts.push({ plugin: plugin.name, message: error.message });
58
+ }
59
+ }
60
+ // A package no longer installed (or disabled) keeps its app and the app's other objects; only its shipped documents go and its row is withdrawn.
61
+ const gone = await db.prepare(`SELECT i.external_id FROM package_publications p JOIN identities i ON i.object = p.app AND i.scheme = 'app'
62
+ WHERE p.withdrawn_at IS NULL ORDER BY i.external_id`).all();
63
+ for (const { external_id } of gone) {
64
+ const app = external_id.replace(/^app:/, '');
65
+ if (published.has(app))
66
+ continue;
67
+ const w = await withdraw(db, app);
68
+ report.deleted += w.deleted;
69
+ }
70
+ // Shipped documents of an app that never had a publication row (written by a release before the publisher) are removed the same way.
71
+ const stale = await db.prepare(`SELECT DISTINCT o.owner AS id FROM objects o JOIN documents d ON d.object = o.id
72
+ WHERE o.type = 'document' AND o.deleted_at IS NULL AND d.source_path IS NOT NULL`).all();
73
+ for (const { id } of stale) {
74
+ const owner = (await getById(db, id));
75
+ const ext = await db.prepare(`SELECT external_id FROM identities WHERE object = ? AND scheme = 'app'`).get(id);
76
+ if (ext !== undefined && published.has(ext.external_id.replace(/^app:/, '')))
77
+ continue;
78
+ await deleteMissing(db, owner, new Set(), report, async (cwd) => await checkoutsForCwd(db, cwd));
79
+ }
12
80
  const holders = new Set();
13
81
  for (const plugin of appPlugins()) {
14
82
  const scopes = plugin.manifest.scopes ?? [];
15
83
  ensurePluginAppToken(plugin.name, scopes);
16
84
  holders.add(plugin.name);
17
- tokens.granted.push({ plugin: plugin.name, scopes: [...scopes] });
85
+ report.tokens.granted.push({ plugin: plugin.name, scopes: [...scopes] });
18
86
  }
19
- const stale = new Set();
87
+ const staleTokens = new Set();
20
88
  for (const entry of listScopedApiTokens('user')) {
21
89
  if (!entry.token.startsWith('crtr_app_') || !entry.grantee.startsWith('app:'))
22
90
  continue;
23
91
  const plugin = entry.grantee.slice('app:'.length);
24
92
  if (!holders.has(plugin))
25
- stale.add(plugin);
93
+ staleTokens.add(plugin);
26
94
  }
27
- for (const plugin of stale)
95
+ for (const plugin of staleTokens)
28
96
  if (revokePluginAppTokens(plugin))
29
- tokens.revoked.push(plugin);
30
- return { ...documents, kinds, tokens };
97
+ report.tokens.revoked.push(plugin);
98
+ return report;
31
99
  }
@@ -4,12 +4,11 @@
4
4
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { openDb, withCanvasWrite } from '../../canvas/db.js';
7
- import { hashOf, storable } from '../../graph/bodies.js';
7
+ import { hashOf } from '../../graph/bodies.js';
8
8
  import { personIdAsync } from '../../canvas/person.js';
9
9
  import { CANVAS_SCHEMA_VERSION } from '../../canvas/schema-version.js';
10
10
  import { crtrVersion } from '../../../shared/crtr-version.js';
11
11
  import { BODIES_TABLE, DUMPED_TABLES, RowFileWriter, SEQ_TABLES, bundleKeys, encodeRows, rowLine } from '../bundle/layout.js';
12
- import { parseFrontmatterGeneric } from '../../frontmatter.js';
13
12
  import { inlineMessageBody } from './inline.js';
14
13
  /** A `bodies` part file holds about this much JSON (the encoded lines, before compression); a row that alone exceeds it is a part of its own. */
15
14
  export const PART_JSON_BYTES = 16 * 1024 * 1024;
@@ -33,16 +32,6 @@ function readText(path) {
33
32
  throw error;
34
33
  }
35
34
  }
36
- function readFileOrNull(path) {
37
- try {
38
- return readFileSync(path, 'utf8');
39
- }
40
- catch (error) {
41
- if (error.code === 'ENOENT')
42
- return null;
43
- throw error;
44
- }
45
- }
46
35
  function tooLarge(hash, bytes) {
47
36
  return new Error(`revision text ${hash} is ${bytes} bytes; the import carries at most ${BODY_MAX_BYTES} bytes of text per body`);
48
37
  }
@@ -55,7 +44,7 @@ function assertCanonical(row) {
55
44
  }
56
45
  /** Read the `bodies` rows of `hashes` (in that order) and write them as part files `<phase>-<n>`; returns the part names. Rows are read in pages of about `PAGE_TEXT_BYTES`
57
46
  * of text (sizes first, by `octet_length`), each row is written as one line, and a part is closed when its encoded JSON reaches `PART_JSON_BYTES`. */
58
- async function writeBodyParts(db, out, phase, hashes, extra) {
47
+ async function writeBodyParts(db, out, phase, hashes) {
59
48
  const dir = join(out, 'rows', BODIES_TABLE);
60
49
  mkdirSync(dir, { recursive: true });
61
50
  const parts = [];
@@ -102,12 +91,6 @@ async function writeBodyParts(db, out, phase, hashes, extra) {
102
91
  await fetchPage();
103
92
  i += SIZE_BATCH;
104
93
  }
105
- for (const row of extra) {
106
- const bytes = Buffer.byteLength(row.text, 'utf8');
107
- if (bytes > BODY_MAX_BYTES)
108
- throw tooLarge(row.hash, bytes);
109
- await add(row);
110
- }
111
94
  await close();
112
95
  }
113
96
  catch (error) {
@@ -122,7 +105,7 @@ const allHashes = async (db) => (await db.prepare('SELECT hash FROM bodies ORDER
122
105
  export async function dumpBodiesPre(out) {
123
106
  const db = await openDb();
124
107
  const hashes = await allHashes(db);
125
- const parts = await writeBodyParts(db, out, 'pre', hashes, []);
108
+ const parts = await writeBodyParts(db, out, 'pre', hashes);
126
109
  const pre = { hashes, parts, count: hashes.length, canonical: true };
127
110
  writeFileSync(join(out, PRE_FILE), JSON.stringify(pre));
128
111
  return { parts, count: hashes.length };
@@ -197,28 +180,21 @@ export async function dumpRows(out, kind) {
197
180
  const environment = await db.prepare(`SELECT object FROM environments WHERE kind = 'machine' ORDER BY object LIMIT 1`).get();
198
181
  const source = { installId, kind, person, release: crtrVersion(), schemaVersion: CANVAS_SCHEMA_VERSION, counts, ...(environment === undefined ? {} : { environmentObject: environment.object }) };
199
182
  const requests = await db.prepare('SELECT request_id, request_dir, file FROM human_requests ORDER BY request_id').all();
200
- // A shipped document's body is its file's body after the frontmatter (`bodyOf`); a file the source no longer has is skipped. The text travels as a `bodies` row.
201
- const shipped = await db.prepare('SELECT object, source_path FROM documents WHERE source_path IS NOT NULL ORDER BY object').all();
183
+ // A shipped document's text is its head revision's `bodies` row, the same row every reader gets; `source_path` is provenance only and is never opened.
184
+ // The row travels with the table, so the bundle needs only the hash.
185
+ const shipped = await db.prepare(`SELECT d.object, r.body_hash FROM documents d JOIN revisions r ON r.document = d.object
186
+ WHERE d.source_path IS NOT NULL AND NOT EXISTS (SELECT 1 FROM revision_parents p WHERE p.parent = r.rev_id) ORDER BY d.object, r.number DESC, r.rev_id`).all();
202
187
  const shippedBodies = {};
203
- const shippedRows = new Map();
204
- for (const doc of shipped) {
205
- const file = readFileOrNull(doc.source_path);
206
- if (file === null)
207
- continue;
208
- const text = storable(parseFrontmatterGeneric(file).body);
209
- const hash = hashOf(text);
210
- shippedBodies[doc.object] = hash;
211
- shippedRows.set(hash, { hash, text });
212
- }
213
- // `bodies` rows added since the pre-pause dump (all of them when there was none), and the shipped texts the pre-pause dump did not hold.
188
+ for (const doc of shipped)
189
+ shippedBodies[doc.object] ??= doc.body_hash;
190
+ // `bodies` rows added since the pre-pause dump (all of them when there was none); the shipped texts are among them.
214
191
  const pre = readPre(out);
215
192
  const had = new Set(pre.hashes);
216
193
  const now = await allHashes(db);
217
194
  assertPreCurrent(pre, new Set(now));
218
195
  const fresh = now.filter((hash) => !had.has(hash));
219
- const added = new Set(fresh);
220
- const pauseParts = await writeBodyParts(db, out, 'pause', fresh, [...shippedRows.values()].filter((row) => !had.has(row.hash) && !added.has(row.hash)));
221
- const bodyCount = pre.count + fresh.length + [...shippedRows.values()].filter((row) => !had.has(row.hash) && !added.has(row.hash)).length;
196
+ const pauseParts = await writeBodyParts(db, out, 'pause', fresh);
197
+ const bodyCount = pre.count + fresh.length;
222
198
  counts[BODIES_TABLE] = bodyCount;
223
199
  source.bodyParts = [...pre.parts, ...pauseParts];
224
200
  const running = await db.prepare(`SELECT n.node_id, r.grantee, n.cwd, w.path AS worktree FROM nodes n JOIN node_rows r ON r.node_id = n.node_id LEFT JOIN worktrees w ON w.node_id = n.node_id AND w.state = 'open'