@happyvertical/smrt-app-shell 0.60.18 → 0.61.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/AGENTS.md +51 -9
- package/README.md +378 -23
- package/agents/draft-ui.md +74 -0
- package/agents/engine.md +81 -0
- package/agents/shared-pieces.md +127 -0
- package/dist/engine/app-runtime.d.ts +7 -0
- package/dist/engine/app-runtime.js +30 -3
- package/dist/engine/commands.d.ts +23 -0
- package/dist/engine/commands.js +29 -0
- package/dist/engine/draft.d.ts +31 -0
- package/dist/engine/draft.js +88 -0
- package/dist/engine/host.d.ts +175 -1
- package/dist/engine/host.js +501 -9
- package/dist/engine/index.d.ts +4 -2
- package/dist/engine/index.js +2 -1
- package/dist/engine/publish.d.ts +100 -0
- package/dist/engine/publish.js +218 -0
- package/dist/engine/store.d.ts +8 -0
- package/dist/engine/store.js +8 -0
- package/dist/engine/worker-protocol.d.ts +1 -1
- package/dist/engine/worker-protocol.js +1 -0
- package/dist/lib/objects/App.d.ts +42 -1
- package/dist/lib/objects/App.js +72 -4
- package/dist/lib/objects/AppDraft.d.ts +31 -0
- package/dist/lib/objects/AppDraft.js +73 -0
- package/dist/lib/objects/AppDraftVersion.d.ts +25 -0
- package/dist/lib/objects/AppDraftVersion.js +68 -0
- package/dist/lib/objects/Approval.d.ts +4 -4
- package/dist/lib/objects/Approval.js +3 -3
- package/dist/lib/objects/index.d.ts +2 -0
- package/dist/lib/objects/index.js +2 -0
- package/dist/lib/server/approval-store.d.ts +6 -0
- package/dist/lib/server/approval-store.js +102 -0
- package/dist/lib/server/approvals.d.ts +15 -0
- package/dist/lib/server/approvals.js +62 -43
- package/dist/lib/server/builder.d.ts +18 -10
- package/dist/lib/server/builder.js +46 -18
- package/dist/lib/server/drafts.d.ts +100 -0
- package/dist/lib/server/drafts.js +324 -0
- package/dist/lib/server/events.d.ts +14 -0
- package/dist/lib/server/events.js +3 -0
- package/dist/lib/server/host.d.ts +44 -2
- package/dist/lib/server/host.js +155 -11
- package/dist/lib/server/results.d.ts +6 -0
- package/dist/lib/server/results.js +8 -0
- package/dist/lib/server/target.d.ts +24 -0
- package/dist/lib/server/target.js +64 -0
- package/dist/lib/server/version-store.d.ts +40 -1
- package/dist/lib/server/version-store.js +144 -32
- package/dist/manifest.json +699 -6
- package/dist/smrt-knowledge.okf.md +19 -2
- package/package.json +19 -15
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { type AppHost, type PublishPreview, type PublishResult } from '../../engine/index.ts';
|
|
2
|
+
import { type PendingApproval } from './approvals.ts';
|
|
3
|
+
import type { Target } from './target.ts';
|
|
4
|
+
/** What the UI and tools see of the open draft. */
|
|
5
|
+
export interface DraftInfo {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
status: 'open';
|
|
9
|
+
/** The live version the draft was copied from. */
|
|
10
|
+
baseVersion: number;
|
|
11
|
+
/** The draft's current version (null when its copy cannot be opened). */
|
|
12
|
+
version: number | null;
|
|
13
|
+
/** Changes made in the draft (its versions after the base). */
|
|
14
|
+
changes: number;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
createdBy: string;
|
|
17
|
+
/** The live version now. */
|
|
18
|
+
liveVersion: number;
|
|
19
|
+
/** Live structure is frozen while a draft is open. */
|
|
20
|
+
liveFrozen: boolean;
|
|
21
|
+
/** False when it cannot be published (see `problem`); discard is always offered. */
|
|
22
|
+
publishable: boolean;
|
|
23
|
+
problem: string | null;
|
|
24
|
+
/** What opening the draft's copy found: `rolled-back` = an interrupted publish was undone (publish again). */
|
|
25
|
+
recovery: 'none' | 'rolled-forward' | 'rolled-back';
|
|
26
|
+
}
|
|
27
|
+
export type DraftPublishResult = Omit<PublishResult, 'snapshot'> & {
|
|
28
|
+
draft: string;
|
|
29
|
+
};
|
|
30
|
+
/** The open draft, or null. Safe to call for any request. */
|
|
31
|
+
export declare function draftInfo(): Promise<DraftInfo | null>;
|
|
32
|
+
/** Start a draft. Live structure is frozen from here until it is published or discarded. */
|
|
33
|
+
export declare function startDraft(name: unknown, ownerId: string): Promise<DraftInfo>;
|
|
34
|
+
/**
|
|
35
|
+
* What publishing would do (a rehearsal on a copy of the CURRENT live data;
|
|
36
|
+
* live is only read). `preview.digest` pins the answer for the publish Approval.
|
|
37
|
+
*/
|
|
38
|
+
export declare function previewDraft(): Promise<{
|
|
39
|
+
draft: DraftInfo;
|
|
40
|
+
preview: PublishPreview;
|
|
41
|
+
}>;
|
|
42
|
+
/**
|
|
43
|
+
* Ask to publish: ALWAYS an Approval, pinned to the digest of a preview taken
|
|
44
|
+
* now. `expect`, when given, is the digest the person reviewed: a draft that
|
|
45
|
+
* changed since is refused (review it again), not published.
|
|
46
|
+
*/
|
|
47
|
+
export declare function requestPublish(expect: unknown, requestedBy: string): Promise<PendingApproval>;
|
|
48
|
+
/**
|
|
49
|
+
* Apply an APPROVED publish. Called only from approvals.ts, which passes the
|
|
50
|
+
* engine call (it owns the `approved` flag). Refuses if this is not the open
|
|
51
|
+
* draft any more, or if the draft is not exactly what the approval was pinned
|
|
52
|
+
* to (the engine compares the digest against the chain as it is now).
|
|
53
|
+
*/
|
|
54
|
+
export declare function publishApproved(action: {
|
|
55
|
+
draftId: string;
|
|
56
|
+
digest: string;
|
|
57
|
+
}, engine: (live: AppHost, draft: AppHost, options: {
|
|
58
|
+
expect: string;
|
|
59
|
+
draftName: string;
|
|
60
|
+
}) => Promise<PublishResult>): Promise<DraftPublishResult>;
|
|
61
|
+
/** Discard the open draft now: closes it, deletes its copy, thaws live. Owner's direct action. */
|
|
62
|
+
export declare function discardNow(expectId?: string): Promise<{
|
|
63
|
+
discarded: string;
|
|
64
|
+
}>;
|
|
65
|
+
/** Ask to discard (a tool's way): an Approval. */
|
|
66
|
+
export declare function requestDiscard(requestedBy: string): Promise<PendingApproval>;
|
|
67
|
+
/**
|
|
68
|
+
* The app as one copy now: `{ mode, draft, frozen, ...describe }`. `mode` is
|
|
69
|
+
* the copy this describes; `draft` is the open draft (the same object whichever
|
|
70
|
+
* copy is described, so a view of live can show the draft strip); `frozen` is
|
|
71
|
+
* whether THIS copy's structure is frozen.
|
|
72
|
+
*/
|
|
73
|
+
export declare function describeCopy(target: Target): Promise<{
|
|
74
|
+
name: string;
|
|
75
|
+
version: number;
|
|
76
|
+
recipes: string[];
|
|
77
|
+
models: Record<string, import("../../engine/definition.ts").DescribedModel>;
|
|
78
|
+
mode: "live" | "draft";
|
|
79
|
+
draft: DraftInfo | null;
|
|
80
|
+
frozen: boolean;
|
|
81
|
+
}>;
|
|
82
|
+
/**
|
|
83
|
+
* The versions of one copy. Live: a published draft's versions carry
|
|
84
|
+
* `publish: { draftId, name }` so History groups them. Draft: the live
|
|
85
|
+
* version it was copied from (its base) is left out, it is not a change made
|
|
86
|
+
* there.
|
|
87
|
+
*/
|
|
88
|
+
export declare function historyFor(target: Target): Promise<{
|
|
89
|
+
publish: {
|
|
90
|
+
draftId: string;
|
|
91
|
+
name: string;
|
|
92
|
+
} | null;
|
|
93
|
+
version: number;
|
|
94
|
+
parent: number | null;
|
|
95
|
+
author: string;
|
|
96
|
+
summary: string;
|
|
97
|
+
createdAt: string;
|
|
98
|
+
purged: boolean;
|
|
99
|
+
current: boolean;
|
|
100
|
+
}[]>;
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// The draft lifecycle: start, describe, preview, publish, discard.
|
|
2
|
+
//
|
|
3
|
+
// One draft at a time. The draft is a second app host over a COPY of the live
|
|
4
|
+
// data (engine: `forkDraft`); live keeps serving records but its structure is
|
|
5
|
+
// FROZEN while the draft is open (frozen again after every restart, in
|
|
6
|
+
// host.ts). Publishing replays the draft's changes onto live and is ALWAYS an
|
|
7
|
+
// Approval pinned to the preview's digest; discarding is a direct owner action
|
|
8
|
+
// from the UI and an Approval when a tool asks. All lifecycle changes run one
|
|
9
|
+
// at a time (`exclusive`), so a start cannot race a publish or a discard.
|
|
10
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
11
|
+
import { join, resolve } from 'node:path';
|
|
12
|
+
import { discardDraftDirectory, EngineError, } from "../../engine/index.js";
|
|
13
|
+
import { AppDraftCollection } from "../objects/AppDraft.js";
|
|
14
|
+
import { requestApproval, } from "./approvals.js";
|
|
15
|
+
import { publish } from "./events.js";
|
|
16
|
+
import { DRAFT_DIRNAME, dataRoot, getDraftSlot, getHost, getLiveStore, getShellDb, hostFor, setDraftSlot, wireHostEvents, } from "./host.js";
|
|
17
|
+
import { DraftVersionStore } from "./version-store.js";
|
|
18
|
+
let tail = Promise.resolve();
|
|
19
|
+
/** Run lifecycle work one at a time. */
|
|
20
|
+
function exclusive(fn) {
|
|
21
|
+
const run = tail.then(fn, fn);
|
|
22
|
+
tail = run.catch(() => { });
|
|
23
|
+
return run;
|
|
24
|
+
}
|
|
25
|
+
function clean(name) {
|
|
26
|
+
const text = typeof name === 'string'
|
|
27
|
+
? Array.from(name)
|
|
28
|
+
.filter((ch) => ch.charCodeAt(0) >= 0x20 && ch !== '\u007f')
|
|
29
|
+
.join('')
|
|
30
|
+
.replace(/\s+/g, ' ')
|
|
31
|
+
.trim()
|
|
32
|
+
: '';
|
|
33
|
+
return text ? text.slice(0, 60) : 'Draft';
|
|
34
|
+
}
|
|
35
|
+
function realOf(path) {
|
|
36
|
+
try {
|
|
37
|
+
return realpathSync(path);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return resolve(path);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Why the open draft cannot be published, or null. */
|
|
44
|
+
function unpublishable(slot, live) {
|
|
45
|
+
if (!slot.host)
|
|
46
|
+
return slot.problem ?? 'the draft copy is not open';
|
|
47
|
+
const marker = slot.host.draft;
|
|
48
|
+
if (!marker)
|
|
49
|
+
return 'the draft copy is not marked as a draft';
|
|
50
|
+
if (realOf(marker.liveDir) !== realOf(live.dataDir))
|
|
51
|
+
return 'the draft was made from another app or location (the data folder was restored or moved); it cannot be published. Discard it and start again';
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
/** The open draft, or null. Safe to call for any request. */
|
|
55
|
+
export async function draftInfo() {
|
|
56
|
+
const live = await getHost();
|
|
57
|
+
const slot = await getDraftSlot();
|
|
58
|
+
if (!slot)
|
|
59
|
+
return null;
|
|
60
|
+
const row = await (await AppDraftCollection.create({ db: await getShellDb() })).get(slot.id);
|
|
61
|
+
let changes = 0;
|
|
62
|
+
if (slot.host) {
|
|
63
|
+
try {
|
|
64
|
+
changes = (await slot.host.draftChain()).versions.length;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
changes = 0;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const problem = unpublishable(slot, live);
|
|
71
|
+
return {
|
|
72
|
+
id: slot.id,
|
|
73
|
+
name: slot.name,
|
|
74
|
+
status: 'open',
|
|
75
|
+
baseVersion: Number(row?.baseVersion ?? slot.host?.draft?.baseVersion ?? -1),
|
|
76
|
+
version: slot.host ? slot.host.version : null,
|
|
77
|
+
changes,
|
|
78
|
+
createdAt: String(row?.createdAt ?? ''),
|
|
79
|
+
createdBy: row?.createdBy ?? '',
|
|
80
|
+
liveVersion: live.version,
|
|
81
|
+
liveFrozen: live.frozen,
|
|
82
|
+
publishable: problem === null,
|
|
83
|
+
problem,
|
|
84
|
+
recovery: slot.host?.recovery ?? 'none',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Start a draft. Live structure is frozen from here until it is published or discarded. */
|
|
88
|
+
export function startDraft(name, ownerId) {
|
|
89
|
+
return exclusive(async () => {
|
|
90
|
+
const live = await getHost();
|
|
91
|
+
const open = await getDraftSlot();
|
|
92
|
+
if (open)
|
|
93
|
+
throw new EngineError(`a draft is already open ("${open.name}"); publish or discard it first`, 'refused');
|
|
94
|
+
const db = await getShellDb();
|
|
95
|
+
const dir = join(await dataRoot(), DRAFT_DIRNAME);
|
|
96
|
+
// A leftover copy no row refers to (host.ts removes these at start): the
|
|
97
|
+
// engine only removes a directory that is one of its own drafts.
|
|
98
|
+
if (existsSync(dir))
|
|
99
|
+
discardDraftDirectory(dir);
|
|
100
|
+
const drafts = await AppDraftCollection.create({ db });
|
|
101
|
+
const row = await drafts.create({
|
|
102
|
+
name: clean(name),
|
|
103
|
+
status: 'starting',
|
|
104
|
+
createdBy: ownerId,
|
|
105
|
+
createdAt: new Date().toISOString(),
|
|
106
|
+
});
|
|
107
|
+
await row.save();
|
|
108
|
+
const id = row.id;
|
|
109
|
+
// Frozen BEFORE the copy: no structural change can land between the row
|
|
110
|
+
// and the copy (the copy itself runs in the live change queue).
|
|
111
|
+
live.setFrozen(true);
|
|
112
|
+
let host = null;
|
|
113
|
+
try {
|
|
114
|
+
host = await live.forkDraft(dir, {
|
|
115
|
+
store: new DraftVersionStore(db, id),
|
|
116
|
+
});
|
|
117
|
+
// The draft's store moved the row's pointer: re-read it before updating.
|
|
118
|
+
const fresh = (await drafts.get(id)) ?? row;
|
|
119
|
+
fresh.status = 'open';
|
|
120
|
+
fresh.baseVersion = host.draft?.baseVersion ?? live.version;
|
|
121
|
+
await fresh.save();
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
live.setFrozen(false);
|
|
125
|
+
if (host)
|
|
126
|
+
await host.discard().catch(() => { });
|
|
127
|
+
const failed = await drafts.get(id).catch(() => null);
|
|
128
|
+
if (failed) {
|
|
129
|
+
failed.status = 'discarded';
|
|
130
|
+
failed.note = 'the start failed';
|
|
131
|
+
failed.endedAt = new Date().toISOString();
|
|
132
|
+
await failed.save().catch(() => { });
|
|
133
|
+
}
|
|
134
|
+
throw e;
|
|
135
|
+
}
|
|
136
|
+
wireHostEvents(host, 'draft');
|
|
137
|
+
setDraftSlot({ id, name: row.name, dir, host, problem: null });
|
|
138
|
+
publish({ type: 'draft', status: 'started', id, name: row.name });
|
|
139
|
+
return (await draftInfo());
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
async function usableSlot() {
|
|
143
|
+
const live = await getHost();
|
|
144
|
+
const slot = await getDraftSlot();
|
|
145
|
+
if (!slot)
|
|
146
|
+
throw new EngineError('there is no draft open', 'not_found');
|
|
147
|
+
const problem = unpublishable(slot, live);
|
|
148
|
+
if (problem || !slot.host)
|
|
149
|
+
throw new EngineError(problem ?? 'the draft cannot be used', 'refused');
|
|
150
|
+
return { slot, host: slot.host, live };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* What publishing would do (a rehearsal on a copy of the CURRENT live data;
|
|
154
|
+
* live is only read). `preview.digest` pins the answer for the publish Approval.
|
|
155
|
+
*/
|
|
156
|
+
export async function previewDraft() {
|
|
157
|
+
const { host, live } = await usableSlot();
|
|
158
|
+
const preview = await live.previewPublish(host);
|
|
159
|
+
return { draft: (await draftInfo()), preview };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Ask to publish: ALWAYS an Approval, pinned to the digest of a preview taken
|
|
163
|
+
* now. `expect`, when given, is the digest the person reviewed: a draft that
|
|
164
|
+
* changed since is refused (review it again), not published.
|
|
165
|
+
*/
|
|
166
|
+
export async function requestPublish(expect, requestedBy) {
|
|
167
|
+
if (expect !== undefined && expect !== null && typeof expect !== 'string')
|
|
168
|
+
throw new EngineError('expect must be the digest of a preview', 'invalid');
|
|
169
|
+
const { draft, preview } = await previewDraft();
|
|
170
|
+
if (expect && expect !== preview.digest)
|
|
171
|
+
throw new EngineError('the draft changed since it was reviewed; review it again', 'refused');
|
|
172
|
+
const action = {
|
|
173
|
+
kind: 'publish-draft',
|
|
174
|
+
draftId: draft.id,
|
|
175
|
+
draftName: draft.name,
|
|
176
|
+
digest: preview.digest,
|
|
177
|
+
};
|
|
178
|
+
const n = preview.versions.length;
|
|
179
|
+
return requestApproval(action, {
|
|
180
|
+
summary: `Publish draft "${draft.name}": ${n} change${n === 1 ? '' : 's'} become live`,
|
|
181
|
+
risky: true,
|
|
182
|
+
preview: { draft: draft.name, ...preview },
|
|
183
|
+
}, requestedBy);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Apply an APPROVED publish. Called only from approvals.ts, which passes the
|
|
187
|
+
* engine call (it owns the `approved` flag). Refuses if this is not the open
|
|
188
|
+
* draft any more, or if the draft is not exactly what the approval was pinned
|
|
189
|
+
* to (the engine compares the digest against the chain as it is now).
|
|
190
|
+
*/
|
|
191
|
+
export function publishApproved(action, engine) {
|
|
192
|
+
return exclusive(async () => {
|
|
193
|
+
const { slot, host, live } = await usableSlot();
|
|
194
|
+
if (slot.id !== action.draftId)
|
|
195
|
+
throw new EngineError('this approval was for a draft that is no longer open', 'refused');
|
|
196
|
+
const store = await getLiveStore();
|
|
197
|
+
// The draft's `published` mark rides in the store commit's transaction.
|
|
198
|
+
store.publishing = { draftId: slot.id };
|
|
199
|
+
let result;
|
|
200
|
+
try {
|
|
201
|
+
result = await engine(live, host, {
|
|
202
|
+
expect: action.digest,
|
|
203
|
+
draftName: slot.name,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
store.publishing = null;
|
|
208
|
+
}
|
|
209
|
+
// Final: live has the versions and the row says `published`.
|
|
210
|
+
setDraftSlot(null);
|
|
211
|
+
try {
|
|
212
|
+
await host.discard();
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
// biome-ignore lint/suspicious/noConsole: message only; the stray copy is removed at the next start
|
|
216
|
+
console.error('draft copy not removed after publish:', e instanceof Error ? e.message : 'unknown error');
|
|
217
|
+
}
|
|
218
|
+
live.setFrozen(false);
|
|
219
|
+
publish({
|
|
220
|
+
type: 'draft',
|
|
221
|
+
status: 'published',
|
|
222
|
+
id: slot.id,
|
|
223
|
+
name: slot.name,
|
|
224
|
+
});
|
|
225
|
+
// The retained pre-publish copy's path stays on this machine.
|
|
226
|
+
const { snapshot: _kept, ...shown } = result;
|
|
227
|
+
return { ...shown, draft: slot.name };
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/** Discard the open draft now: closes it, deletes its copy, thaws live. Owner's direct action. */
|
|
231
|
+
export function discardNow(expectId) {
|
|
232
|
+
return exclusive(async () => {
|
|
233
|
+
const live = await getHost();
|
|
234
|
+
const slot = await getDraftSlot();
|
|
235
|
+
if (!slot)
|
|
236
|
+
throw new EngineError('there is no draft open', 'not_found');
|
|
237
|
+
if (expectId && expectId !== slot.id)
|
|
238
|
+
throw new EngineError('this is not the draft that was asked about', 'refused');
|
|
239
|
+
if (slot.host)
|
|
240
|
+
await slot.host.discard();
|
|
241
|
+
else if (existsSync(slot.dir))
|
|
242
|
+
discardDraftDirectory(slot.dir);
|
|
243
|
+
const row = await (await AppDraftCollection.create({ db: await getShellDb() })).get(slot.id);
|
|
244
|
+
if (row) {
|
|
245
|
+
row.status = 'discarded';
|
|
246
|
+
row.note = 'discarded';
|
|
247
|
+
row.endedAt = new Date().toISOString();
|
|
248
|
+
await row.save();
|
|
249
|
+
}
|
|
250
|
+
setDraftSlot(null);
|
|
251
|
+
live.setFrozen(false);
|
|
252
|
+
publish({
|
|
253
|
+
type: 'draft',
|
|
254
|
+
status: 'discarded',
|
|
255
|
+
id: slot.id,
|
|
256
|
+
name: slot.name,
|
|
257
|
+
});
|
|
258
|
+
return { discarded: slot.name };
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
/** Ask to discard (a tool's way): an Approval. */
|
|
262
|
+
export async function requestDiscard(requestedBy) {
|
|
263
|
+
const draft = await draftInfo();
|
|
264
|
+
if (!draft)
|
|
265
|
+
throw new EngineError('there is no draft open', 'not_found');
|
|
266
|
+
return requestApproval({ kind: 'discard-draft', draftId: draft.id, draftName: draft.name }, {
|
|
267
|
+
summary: `Discard draft "${draft.name}" and everything built in it`,
|
|
268
|
+
risky: true,
|
|
269
|
+
preview: {
|
|
270
|
+
draft: draft.name,
|
|
271
|
+
changes: draft.changes,
|
|
272
|
+
note: 'The draft is thrown away. The live app is not changed.',
|
|
273
|
+
},
|
|
274
|
+
}, requestedBy);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The app as one copy now: `{ mode, draft, frozen, ...describe }`. `mode` is
|
|
278
|
+
* the copy this describes; `draft` is the open draft (the same object whichever
|
|
279
|
+
* copy is described, so a view of live can show the draft strip); `frozen` is
|
|
280
|
+
* whether THIS copy's structure is frozen.
|
|
281
|
+
*/
|
|
282
|
+
export async function describeCopy(target) {
|
|
283
|
+
const host = await hostFor(target);
|
|
284
|
+
return {
|
|
285
|
+
mode: target,
|
|
286
|
+
draft: await draftInfo(),
|
|
287
|
+
frozen: host.frozen,
|
|
288
|
+
...host.describe(),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* The versions of one copy. Live: a published draft's versions carry
|
|
293
|
+
* `publish: { draftId, name }` so History groups them. Draft: the live
|
|
294
|
+
* version it was copied from (its base) is left out, it is not a change made
|
|
295
|
+
* there.
|
|
296
|
+
*/
|
|
297
|
+
export async function historyFor(target) {
|
|
298
|
+
const host = await hostFor(target);
|
|
299
|
+
const versions = await host.versions();
|
|
300
|
+
if (target === 'draft') {
|
|
301
|
+
const base = host.draft?.baseVersion;
|
|
302
|
+
return versions
|
|
303
|
+
.filter((v) => v.version !== base)
|
|
304
|
+
.map((v) => ({ ...v, publish: null }));
|
|
305
|
+
}
|
|
306
|
+
const groups = new Map();
|
|
307
|
+
const drafts = await AppDraftCollection.create({ db: await getShellDb() });
|
|
308
|
+
for (const d of await drafts.list({
|
|
309
|
+
where: { status: 'published' },
|
|
310
|
+
limit: 1000,
|
|
311
|
+
})) {
|
|
312
|
+
try {
|
|
313
|
+
for (const n of JSON.parse(d.publishedVersions || '[]'))
|
|
314
|
+
groups.set(n, { draftId: d.id, name: d.name });
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// an unreadable list just leaves that publish ungrouped
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return versions.map((v) => ({
|
|
321
|
+
...v,
|
|
322
|
+
publish: groups.get(v.version) ?? null,
|
|
323
|
+
}));
|
|
324
|
+
}
|
|
@@ -1,15 +1,29 @@
|
|
|
1
|
+
import type { Target } from './target.ts';
|
|
1
2
|
export type ShellEvent = {
|
|
2
3
|
type: 'changed';
|
|
4
|
+
target: Target;
|
|
3
5
|
version: number;
|
|
4
6
|
summary: string;
|
|
5
7
|
} | {
|
|
6
8
|
type: 'records';
|
|
9
|
+
target: Target;
|
|
7
10
|
model: string | null;
|
|
8
11
|
} | {
|
|
9
12
|
type: 'approval';
|
|
10
13
|
id: string;
|
|
11
14
|
status: 'pending' | 'approved' | 'denied' | 'failed';
|
|
12
15
|
summary: string;
|
|
16
|
+
} | {
|
|
17
|
+
/**
|
|
18
|
+
* The draft's life changed. `started`: a draft is open (live structure
|
|
19
|
+
* is now frozen); `published`: its changes are live and it is gone;
|
|
20
|
+
* `discarded`: it is gone and live thawed; `unpublishable`: it was found
|
|
21
|
+
* open but cannot be published (see `describe().draft.problem`).
|
|
22
|
+
*/
|
|
23
|
+
type: 'draft';
|
|
24
|
+
status: 'started' | 'published' | 'discarded' | 'unpublishable';
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
13
27
|
};
|
|
14
28
|
export declare function publish(event: ShellEvent): void;
|
|
15
29
|
/** Subscribe; returns the unsubscribe function. */
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// A small in-process event bus for live updates. Host events ('changed',
|
|
2
2
|
// 'records') and approval events are republished here; the SSE route
|
|
3
3
|
// (`/api/events`) and the MCP route (list_changed) subscribe.
|
|
4
|
+
//
|
|
5
|
+
// Every definition/record event says WHICH COPY changed (`target`), so a view
|
|
6
|
+
// of the live app never reloads for a draft's change and the reverse.
|
|
4
7
|
import { EventEmitter } from 'node:events';
|
|
5
8
|
const bus = new EventEmitter();
|
|
6
9
|
bus.setMaxListeners(100);
|
|
@@ -1,22 +1,64 @@
|
|
|
1
1
|
import type { DatabaseInterface } from '@happyvertical/sql';
|
|
2
2
|
import { AppHost } from '../../engine/index.ts';
|
|
3
|
+
import type { Target } from './target.ts';
|
|
4
|
+
import { ShellVersionStore } from './version-store.ts';
|
|
5
|
+
/** Where the open draft's copy lives under the data root. */
|
|
6
|
+
export declare const DRAFT_DIRNAME = "user-app-draft";
|
|
7
|
+
/** The open draft as the shell holds it. */
|
|
8
|
+
export interface DraftSlot {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
/** The draft's data directory. */
|
|
12
|
+
dir: string;
|
|
13
|
+
/** The draft host; null when it could not be opened (see `problem`). */
|
|
14
|
+
host: AppHost | null;
|
|
15
|
+
/** Why `host` is null, or null. */
|
|
16
|
+
problem: string | null;
|
|
17
|
+
}
|
|
3
18
|
export interface ShellContext {
|
|
4
19
|
/** The shell's database (shell.db: users, App, AppVersion, Approval). */
|
|
5
20
|
db: () => Promise<DatabaseInterface>;
|
|
6
21
|
/** The app-runtime data root. */
|
|
7
22
|
dataRoot: () => Promise<string>;
|
|
23
|
+
/**
|
|
24
|
+
* The runtime's secrets master key (created once on the local profile and
|
|
25
|
+
* kept in its vault key store; `SMRT_SECRET_MASTER_KEY` always wins). Never
|
|
26
|
+
* logged or sent anywhere.
|
|
27
|
+
*/
|
|
28
|
+
secretMasterKey?: () => Promise<string | null>;
|
|
8
29
|
/** Override the app worker entry (tests; defaults to the engine's own). */
|
|
9
30
|
workerUrl?: URL | string;
|
|
10
31
|
}
|
|
11
32
|
export declare function configureShell(next: ShellContext): void;
|
|
12
33
|
export declare function getShellDb(): Promise<DatabaseInterface>;
|
|
34
|
+
/** The runtime's secrets master key, or null when it supplies none. */
|
|
35
|
+
export declare function getShellSecretMasterKey(): Promise<string | null>;
|
|
13
36
|
/** The shell database once `getShellDb()` has resolved. For sync thunks. */
|
|
14
37
|
export declare function shellDbSync(): DatabaseInterface;
|
|
15
38
|
export declare function dataRoot(): Promise<string>;
|
|
16
|
-
/**
|
|
39
|
+
/** Republish a host's events on the bus, tagged with the copy they came from. */
|
|
40
|
+
export declare function wireHostEvents(host: AppHost, target: Target): void;
|
|
41
|
+
/**
|
|
42
|
+
* Open a draft's host. `autoApprove` (risky changes need no approval) is the
|
|
43
|
+
* draft's alone: the engine refuses it unless `dir` holds its draft marker, so
|
|
44
|
+
* this call can never hand it to the live host.
|
|
45
|
+
*/
|
|
46
|
+
export declare function openDraftHost(db: DatabaseInterface, id: string, dir: string): Promise<AppHost>;
|
|
47
|
+
/** The LIVE host, opened on first use. It is never the draft. */
|
|
17
48
|
export declare function getHost(): Promise<AppHost>;
|
|
18
|
-
/** The host
|
|
49
|
+
/** The live host's version store (a publish names its draft on it). */
|
|
50
|
+
export declare function getLiveStore(): Promise<ShellVersionStore>;
|
|
51
|
+
/** The live host if it is already open (sync, for list_changed wiring). */
|
|
19
52
|
export declare function openHost(): AppHost | null;
|
|
53
|
+
/** The open draft (live is open too), or null when there is none. */
|
|
54
|
+
export declare function getDraftSlot(): Promise<DraftSlot | null>;
|
|
55
|
+
/** drafts.ts only: record the draft that just opened, or null once it is gone. */
|
|
56
|
+
export declare function setDraftSlot(slot: DraftSlot | null): void;
|
|
57
|
+
/**
|
|
58
|
+
* The host for a copy the REQUEST named. `live` is always the live host;
|
|
59
|
+
* `draft` is the draft host or a clear refusal, never live.
|
|
60
|
+
*/
|
|
61
|
+
export declare function hostFor(target: Target): Promise<AppHost>;
|
|
20
62
|
export declare function closeHost(): Promise<void>;
|
|
21
63
|
/** Tests: forget everything. */
|
|
22
64
|
export declare function resetShell(): Promise<void>;
|