@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.
Files changed (52) hide show
  1. package/AGENTS.md +51 -9
  2. package/README.md +378 -23
  3. package/agents/draft-ui.md +74 -0
  4. package/agents/engine.md +81 -0
  5. package/agents/shared-pieces.md +127 -0
  6. package/dist/engine/app-runtime.d.ts +7 -0
  7. package/dist/engine/app-runtime.js +30 -3
  8. package/dist/engine/commands.d.ts +23 -0
  9. package/dist/engine/commands.js +29 -0
  10. package/dist/engine/draft.d.ts +31 -0
  11. package/dist/engine/draft.js +88 -0
  12. package/dist/engine/host.d.ts +175 -1
  13. package/dist/engine/host.js +501 -9
  14. package/dist/engine/index.d.ts +4 -2
  15. package/dist/engine/index.js +2 -1
  16. package/dist/engine/publish.d.ts +100 -0
  17. package/dist/engine/publish.js +218 -0
  18. package/dist/engine/store.d.ts +8 -0
  19. package/dist/engine/store.js +8 -0
  20. package/dist/engine/worker-protocol.d.ts +1 -1
  21. package/dist/engine/worker-protocol.js +1 -0
  22. package/dist/lib/objects/App.d.ts +42 -1
  23. package/dist/lib/objects/App.js +72 -4
  24. package/dist/lib/objects/AppDraft.d.ts +31 -0
  25. package/dist/lib/objects/AppDraft.js +73 -0
  26. package/dist/lib/objects/AppDraftVersion.d.ts +25 -0
  27. package/dist/lib/objects/AppDraftVersion.js +68 -0
  28. package/dist/lib/objects/Approval.d.ts +4 -4
  29. package/dist/lib/objects/Approval.js +3 -3
  30. package/dist/lib/objects/index.d.ts +2 -0
  31. package/dist/lib/objects/index.js +2 -0
  32. package/dist/lib/server/approval-store.d.ts +6 -0
  33. package/dist/lib/server/approval-store.js +102 -0
  34. package/dist/lib/server/approvals.d.ts +15 -0
  35. package/dist/lib/server/approvals.js +62 -43
  36. package/dist/lib/server/builder.d.ts +18 -10
  37. package/dist/lib/server/builder.js +46 -18
  38. package/dist/lib/server/drafts.d.ts +100 -0
  39. package/dist/lib/server/drafts.js +324 -0
  40. package/dist/lib/server/events.d.ts +14 -0
  41. package/dist/lib/server/events.js +3 -0
  42. package/dist/lib/server/host.d.ts +44 -2
  43. package/dist/lib/server/host.js +155 -11
  44. package/dist/lib/server/results.d.ts +6 -0
  45. package/dist/lib/server/results.js +8 -0
  46. package/dist/lib/server/target.d.ts +24 -0
  47. package/dist/lib/server/target.js +64 -0
  48. package/dist/lib/server/version-store.d.ts +40 -1
  49. package/dist/lib/server/version-store.js +144 -32
  50. package/dist/manifest.json +699 -6
  51. package/dist/smrt-knowledge.okf.md +19 -2
  52. package/package.json +19 -15
@@ -0,0 +1,25 @@
1
+ import { SmrtCollection, SmrtObject } from '@happyvertical/smrt-core';
2
+ /**
3
+ * One version of a draft's definition (shell.db), the same shape as
4
+ * `AppVersion` plus the draft it belongs to. The draft's FIRST row is a copy of
5
+ * the live version it started from (its base): it is not a change the person
6
+ * made. Never deleted. Written only by `DraftVersionStore`; no generated
7
+ * surface.
8
+ */
9
+ export declare class AppDraftVersion extends SmrtObject {
10
+ draftId: string;
11
+ version: number;
12
+ /** Parent version, `-1` for none. */
13
+ parent: number;
14
+ author: string;
15
+ summary: string;
16
+ /** The whole definition, JSON text. */
17
+ definition: string;
18
+ /** `{ steps, purged? }` JSON text, or empty. */
19
+ migration: string;
20
+ createdAt: string;
21
+ purged: boolean;
22
+ }
23
+ export declare class AppDraftVersionCollection extends SmrtCollection<AppDraftVersion> {
24
+ static readonly _itemClass: typeof AppDraftVersion;
25
+ }
@@ -0,0 +1,68 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { field, SmrtCollection, SmrtObject, smrt, } from '@happyvertical/smrt-core';
8
+ /**
9
+ * One version of a draft's definition (shell.db), the same shape as
10
+ * `AppVersion` plus the draft it belongs to. The draft's FIRST row is a copy of
11
+ * the live version it started from (its base): it is not a change the person
12
+ * made. Never deleted. Written only by `DraftVersionStore`; no generated
13
+ * surface.
14
+ */
15
+ let AppDraftVersion = class AppDraftVersion extends SmrtObject {
16
+ draftId = '';
17
+ version = 0;
18
+ /** Parent version, `-1` for none. */
19
+ parent = -1;
20
+ author = '';
21
+ summary = '';
22
+ /** The whole definition, JSON text. */
23
+ definition = '';
24
+ /** `{ steps, purged? }` JSON text, or empty. */
25
+ migration = '';
26
+ createdAt = '';
27
+ purged = false;
28
+ };
29
+ __decorate([
30
+ field({ type: 'text', required: true })
31
+ ], AppDraftVersion.prototype, "draftId", void 0);
32
+ __decorate([
33
+ field({ type: 'integer', required: true })
34
+ ], AppDraftVersion.prototype, "version", void 0);
35
+ __decorate([
36
+ field({ type: 'integer' })
37
+ ], AppDraftVersion.prototype, "parent", void 0);
38
+ __decorate([
39
+ field({ type: 'text' })
40
+ ], AppDraftVersion.prototype, "author", void 0);
41
+ __decorate([
42
+ field({ type: 'text' })
43
+ ], AppDraftVersion.prototype, "summary", void 0);
44
+ __decorate([
45
+ field({ type: 'text' })
46
+ ], AppDraftVersion.prototype, "definition", void 0);
47
+ __decorate([
48
+ field({ type: 'text' })
49
+ ], AppDraftVersion.prototype, "migration", void 0);
50
+ __decorate([
51
+ field({ type: 'text' })
52
+ ], AppDraftVersion.prototype, "createdAt", void 0);
53
+ __decorate([
54
+ field({ type: 'boolean' })
55
+ ], AppDraftVersion.prototype, "purged", void 0);
56
+ AppDraftVersion = __decorate([
57
+ smrt({
58
+ tableName: 'app_draft_versions',
59
+ api: false,
60
+ mcp: false,
61
+ cli: false,
62
+ conflictColumns: ['id'],
63
+ })
64
+ ], AppDraftVersion);
65
+ export { AppDraftVersion };
66
+ export class AppDraftVersionCollection extends SmrtCollection {
67
+ static _itemClass = AppDraftVersion;
68
+ }
@@ -1,10 +1,10 @@
1
1
  import { SmrtCollection, SmrtObject } from '@happyvertical/smrt-core';
2
2
  import { type ApprovalOutcome } from '../server/approvals.ts';
3
- export type ApprovalStatus = 'pending' | 'approved' | 'denied' | 'failed';
3
+ export type ApprovalStatus = 'pending' | 'approved' | 'denied' | 'failed' | 'accepted' | 'executing' | 'expired';
4
4
  /**
5
- * A risky change waiting for the owner. Created by the shell when a risky
6
- * builder method, app tool or REST DELETE is attempted; applied only by
7
- * {@link Approval.decide} (the one place the engine's `approved` flag is set).
5
+ * Compatibility facade for installed Tricia approval storage and generated
6
+ * decide/pending operations. The shared OwnerApprovalAuthority owns decisions
7
+ * and consumption; the shell retains only its action/draft execution policy.
8
8
  */
9
9
  export declare class Approval extends SmrtObject {
10
10
  /** The action to run on approval, JSON (`ApprovalAction`). */
@@ -11,9 +11,9 @@ import { assertOwner } from "../server/owner.js";
11
11
  // statically) and is repeated on the collection (core applies a collection's
12
12
  // @smrt() config to its item class).
13
13
  /**
14
- * A risky change waiting for the owner. Created by the shell when a risky
15
- * builder method, app tool or REST DELETE is attempted; applied only by
16
- * {@link Approval.decide} (the one place the engine's `approved` flag is set).
14
+ * Compatibility facade for installed Tricia approval storage and generated
15
+ * decide/pending operations. The shared OwnerApprovalAuthority owns decisions
16
+ * and consumption; the shell retains only its action/draft execution policy.
17
17
  */
18
18
  let Approval = class Approval extends SmrtObject {
19
19
  /** The action to run on approval, JSON (`ApprovalAction`). */
@@ -1,4 +1,6 @@
1
1
  export { App, AppCollection } from './App.ts';
2
+ export { AppDraft, AppDraftCollection } from './AppDraft.ts';
3
+ export { AppDraftVersion, AppDraftVersionCollection, } from './AppDraftVersion.ts';
2
4
  export { Approval, ApprovalCollection } from './Approval.ts';
3
5
  export { AppVersion, AppVersionCollection } from './AppVersion.ts';
4
6
  export { AssistantMessage, AssistantMessageCollection, } from './AssistantMessage.ts';
@@ -1,4 +1,6 @@
1
1
  export { App, AppCollection } from "./App.js";
2
+ export { AppDraft, AppDraftCollection } from "./AppDraft.js";
3
+ export { AppDraftVersion, AppDraftVersionCollection, } from "./AppDraftVersion.js";
2
4
  export { Approval, ApprovalCollection } from "./Approval.js";
3
5
  export { AppVersion, AppVersionCollection } from "./AppVersion.js";
4
6
  export { AssistantMessage, AssistantMessageCollection, } from "./AssistantMessage.js";
@@ -0,0 +1,6 @@
1
+ import { OwnerApprovalAuthority } from '@happyvertical/smrt-app-runtime';
2
+ /** The local product has one tenant. Refuse ambiguous legacy ownership. */
3
+ export declare function shellApprovalAuthority(): Promise<{
4
+ tenantId: string;
5
+ authority: OwnerApprovalAuthority;
6
+ }>;
@@ -0,0 +1,102 @@
1
+ // Storage compatibility for installed Tricia databases. The shared authority
2
+ // owns decisions/consumption; this adapter keeps the existing ids and history.
3
+ import { OwnerApprovalAuthority, } from '@happyvertical/smrt-app-runtime';
4
+ import { TenantCollection } from '@happyvertical/smrt-users';
5
+ import { ApprovalCollection } from "../objects/Approval.js";
6
+ import { parseJson } from "./approvals.js";
7
+ import { getShellDb } from "./host.js";
8
+ import { isOwner } from "./owner.js";
9
+ /** The local product has one tenant. Refuse ambiguous legacy ownership. */
10
+ export async function shellApprovalAuthority() {
11
+ const db = await getShellDb();
12
+ const tenants = await (await TenantCollection.create({ db })).list({
13
+ limit: 2,
14
+ });
15
+ if (tenants.length !== 1 || !tenants[0].id)
16
+ throw new Error('approval storage requires the single installation tenant');
17
+ const tenantId = tenants[0].id;
18
+ const collection = await ApprovalCollection.create({ db });
19
+ const record = (row) => ({
20
+ id: row.id,
21
+ tenantId,
22
+ requestedBy: row.requestedBy,
23
+ tool: 'shell.action',
24
+ callId: row.actionKey,
25
+ args: { action: row.command },
26
+ summary: row.summary,
27
+ context: { preview: row.preview },
28
+ createdAt: new Date(row.createdAt).getTime(),
29
+ expiresAt: Number.MAX_SAFE_INTEGER,
30
+ // Old approved rows without results may have crashed during execution.
31
+ // Never replay those. New decisions use accepted until consume succeeds.
32
+ status: row.status === 'accepted'
33
+ ? 'approved'
34
+ : row.status === 'approved'
35
+ ? row.result
36
+ ? 'succeeded'
37
+ : 'executing'
38
+ : row.status,
39
+ decidedBy: row.decidedBy || null,
40
+ decidedAt: row.decidedAt ? new Date(row.decidedAt).getTime() : null,
41
+ ...(row.result ? { result: parseJson(row.result) } : {}),
42
+ });
43
+ const store = {
44
+ supportsNonExpiring: true,
45
+ async insert(value) {
46
+ if (value.tenantId !== tenantId)
47
+ throw new Error('foreign approval tenant');
48
+ const row = await collection.create({
49
+ id: value.id,
50
+ command: String(value.args.action),
51
+ actionKey: value.callId,
52
+ summary: value.summary,
53
+ preview: String(value.context.preview),
54
+ status: 'pending',
55
+ requestedBy: value.requestedBy,
56
+ createdAt: new Date(value.createdAt).toISOString(),
57
+ });
58
+ await row.save();
59
+ },
60
+ async get(tenant, id) {
61
+ if (tenant !== tenantId)
62
+ return null;
63
+ const row = await collection.get(id);
64
+ return row ? record(row) : null;
65
+ },
66
+ async list(tenant) {
67
+ return tenant === tenantId
68
+ ? (await collection.list({ limit: 500 })).map(record)
69
+ : [];
70
+ },
71
+ async transition(tenant, id, from, to, now, patch) {
72
+ if (tenant !== tenantId)
73
+ return false;
74
+ const persisted = (state) => state === 'approved'
75
+ ? 'accepted'
76
+ : state === 'succeeded'
77
+ ? 'approved'
78
+ : state;
79
+ // One conditional statement on the same database is the cross-process
80
+ // authority, including consumption BEFORE any host effect.
81
+ const sets = ['status = ?'];
82
+ const values = [persisted(to)];
83
+ if (from === 'pending') {
84
+ sets.push('decided_by = ?', 'decided_at = ?');
85
+ values.push(patch?.decidedBy ?? '', new Date(now).toISOString());
86
+ }
87
+ if (to === 'succeeded' || to === 'failed') {
88
+ sets.push('result = ?');
89
+ values.push(JSON.stringify(patch?.result ?? null));
90
+ }
91
+ const changed = await db.query(`UPDATE approvals SET ${sets.join(', ')} WHERE id = ? AND status = ? RETURNING id`, ...values, id, persisted(from));
92
+ return changed.rows.length === 1;
93
+ },
94
+ };
95
+ return {
96
+ tenantId,
97
+ authority: new OwnerApprovalAuthority(store, isOwner, Date.now, {
98
+ nonExpiring: true,
99
+ maxInputChars: 4_000_000,
100
+ }),
101
+ };
102
+ }
@@ -17,6 +17,19 @@ export type ApprovalAction = {
17
17
  kind: 'record-delete';
18
18
  model: string;
19
19
  id: string;
20
+ }
21
+ /** Publish the open draft; pinned to the digest of the preview the owner reviewed. */
22
+ | {
23
+ kind: 'publish-draft';
24
+ draftId: string;
25
+ draftName: string;
26
+ digest: string;
27
+ }
28
+ /** Discard the open draft (a tool asked; the UI discards directly). */
29
+ | {
30
+ kind: 'discard-draft';
31
+ draftId: string;
32
+ draftName: string;
20
33
  };
21
34
  /** The answer a refused-until-approved call gives instead of applying. */
22
35
  export interface PendingApproval {
@@ -49,6 +62,8 @@ export interface ApprovalOutcome {
49
62
  error?: string;
50
63
  }
51
64
  export declare function actionKey(action: ApprovalAction): string;
65
+ /** Read a legacy JSON column without breaking saved history on malformed text. */
66
+ export declare function parseJson(text: string): unknown;
52
67
  /**
53
68
  * Record a pending approval for `action`. An identical pending action reuses
54
69
  * its row, so retrying a refused call never piles up cards.
@@ -4,9 +4,13 @@
4
4
  // `approved: true`; nothing taken from a request body can set it.
5
5
  import { AsyncLocalStorage } from 'node:async_hooks';
6
6
  import { createHash } from 'node:crypto';
7
+ import { OwnerApprovalError } from '@happyvertical/smrt-app-runtime';
7
8
  import { ApprovalCollection } from "../objects/Approval.js";
9
+ import { shellApprovalAuthority } from "./approval-store.js";
10
+ import { discardNow, publishApproved } from "./drafts.js";
8
11
  import { publish } from "./events.js";
9
12
  import { getHost, getShellDb } from "./host.js";
13
+ import { publicChangeResult } from "./results.js";
10
14
  // Out-of-band record of the approvals a call issued. `requestApproval` is the
11
15
  // only writer, so a caller (the assistant's turn loop) can tell "this call
12
16
  // became an Approval" from a tool result whose text merely looks like one.
@@ -17,7 +21,6 @@ export async function collectIssuedApprovals(fn) {
17
21
  const value = await issuedScope.run(issued, fn);
18
22
  return { value, issued };
19
23
  }
20
- const MAX_RESULT_CHARS = 64_000;
21
24
  function canonical(value) {
22
25
  if (Array.isArray(value))
23
26
  return `[${value.map(canonical).join(',')}]`;
@@ -34,7 +37,8 @@ function canonical(value) {
34
37
  export function actionKey(action) {
35
38
  return createHash('sha256').update(canonical(action)).digest('hex');
36
39
  }
37
- function parseJson(text) {
40
+ /** Read a legacy JSON column without breaking saved history on malformed text. */
41
+ export function parseJson(text) {
38
42
  if (!text)
39
43
  return null;
40
44
  try {
@@ -51,14 +55,19 @@ function iso(value) {
51
55
  function view(row) {
52
56
  return {
53
57
  id: row.id,
54
- status: row.status,
58
+ status: row.status === 'accepted' || row.status === 'executing'
59
+ ? 'approved'
60
+ : row.status,
55
61
  summary: row.summary,
56
62
  preview: parseJson(row.preview),
57
63
  action: parseJson(row.command),
58
64
  requestedBy: row.requestedBy,
59
65
  decidedBy: row.decidedBy,
60
66
  decidedAt: iso(row.decidedAt),
61
- result: parseJson(row.result),
67
+ result: parseJson(row.command) &&
68
+ parseJson(row.command).kind === 'command'
69
+ ? publicChangeResult(parseJson(row.result))
70
+ : parseJson(row.result),
62
71
  createdAt: iso(row.createdAt),
63
72
  };
64
73
  }
@@ -75,25 +84,29 @@ export async function requestApproval(action, plan, requestedBy) {
75
84
  const existing = (await coll.list({ where: { actionKey: key, status: 'pending' }, limit: 1 }))[0];
76
85
  const row = existing ??
77
86
  (await (async () => {
78
- const created = await coll.create({
79
- command: JSON.stringify(action),
80
- actionKey: key,
81
- summary: plan.summary,
82
- // Flat: the Approval card reads `preview.model`, `preview.code`, ...
83
- preview: JSON.stringify({
84
- ...(plan.preview ?? {}),
85
- ...(plan.affectedMethods
86
- ? { affectedMethods: plan.affectedMethods }
87
- : {}),
88
- }),
89
- status: 'pending',
87
+ const { authority, tenantId } = await shellApprovalAuthority();
88
+ const saved = await authority.request({
89
+ tenantId,
90
90
  requestedBy,
91
- createdAt: new Date().toISOString(),
91
+ tool: 'shell.action',
92
+ callId: key,
93
+ // Definitions may nest more deeply than a tool argument envelope.
94
+ // Keep their already-validated JSON as inert data, as the legacy table does.
95
+ args: { action: JSON.stringify(action) },
96
+ summary: plan.summary,
97
+ context: {
98
+ preview: JSON.stringify({
99
+ ...(plan.preview ?? {}),
100
+ ...(plan.affectedMethods
101
+ ? { affectedMethods: plan.affectedMethods }
102
+ : {}),
103
+ }),
104
+ },
92
105
  });
93
- await created.save();
106
+ const created = (await coll.get(saved.id));
94
107
  publish({
95
108
  type: 'approval',
96
- id: created.id,
109
+ id: saved.id,
97
110
  status: 'pending',
98
111
  summary: plan.summary,
99
112
  });
@@ -125,7 +138,6 @@ export async function listApprovalViews(filter = {}) {
125
138
  export async function pendingApprovalCount() {
126
139
  return (await listApprovalViews({ status: 'pending' })).length;
127
140
  }
128
- const inFlight = new Set();
129
141
  function isAction(v) {
130
142
  const a = v;
131
143
  if (!a || typeof a !== 'object')
@@ -138,6 +150,12 @@ function isAction(v) {
138
150
  return typeof a.method === 'string' && typeof a.url === 'string';
139
151
  if (a.kind === 'record-delete')
140
152
  return typeof a.model === 'string' && typeof a.id === 'string';
153
+ if (a.kind === 'publish-draft')
154
+ return (typeof a.draftId === 'string' &&
155
+ typeof a.digest === 'string' &&
156
+ typeof a.draftName === 'string');
157
+ if (a.kind === 'discard-draft')
158
+ return typeof a.draftId === 'string' && typeof a.draftName === 'string';
141
159
  return false;
142
160
  }
143
161
  /**
@@ -165,16 +183,12 @@ export async function decideApproval(id, approve, decidedBy) {
165
183
  const row = await coll.get(id);
166
184
  if (!row)
167
185
  throw new Error('approval not found');
168
- if (row.status !== 'pending' || inFlight.has(id))
186
+ if (row.status !== 'pending')
169
187
  throw new Error(`approval is already ${row.status}`);
170
- inFlight.add(id);
188
+ const { authority, tenantId } = await shellApprovalAuthority();
171
189
  try {
172
- const decidedAt = new Date().toISOString();
173
190
  if (!approve) {
174
- row.status = 'denied';
175
- row.decidedBy = decidedBy;
176
- row.decidedAt = decidedAt;
177
- await row.save();
191
+ await authority.decide({ id: decidedBy, tenantId }, id, false);
178
192
  publish({
179
193
  type: 'approval',
180
194
  id,
@@ -186,20 +200,28 @@ export async function decideApproval(id, approve, decidedBy) {
186
200
  const action = parseJson(row.command);
187
201
  if (!isAction(action))
188
202
  throw new Error('the stored action is invalid');
189
- // Mark it taken before running anything: a second decide sees "approved".
190
- row.status = 'approved';
191
- row.decidedBy = decidedBy;
192
- row.decidedAt = decidedAt;
193
- await row.save();
203
+ await authority.decide({ id: decidedBy, tenantId }, id, true);
204
+ await authority.consume(tenantId, id);
194
205
  let result;
195
206
  let error;
196
207
  try {
208
+ // Approvals only ever run against the LIVE host: the draft host
209
+ // approves its own risky changes (autoApprove), so none is ever asked.
197
210
  const host = await getHost();
198
- if (action.kind === 'command')
199
- result = await host.execute(action.command, {
211
+ if (action.kind === 'publish-draft')
212
+ result = await publishApproved(action, (live, draft, o) => live.publishDraft(draft, {
200
213
  author: row.requestedBy || decidedBy,
201
214
  approved: true,
202
- });
215
+ expect: o.expect,
216
+ draftName: o.draftName,
217
+ }));
218
+ else if (action.kind === 'discard-draft')
219
+ result = await discardNow(action.draftId);
220
+ else if (action.kind === 'command')
221
+ result = publicChangeResult(await host.execute(action.command, {
222
+ author: row.requestedBy || decidedBy,
223
+ approved: true,
224
+ }));
203
225
  else if (action.kind === 'tool')
204
226
  result = await host.callAppTool(action.name, action.args, {
205
227
  approved: true,
@@ -217,12 +239,7 @@ export async function decideApproval(id, approve, decidedBy) {
217
239
  error = e instanceof Error ? e.message : String(e);
218
240
  }
219
241
  row.status = error === undefined ? 'approved' : 'failed';
220
- const text = JSON.stringify(error === undefined ? result : { error });
221
- row.result =
222
- text.length > MAX_RESULT_CHARS
223
- ? JSON.stringify({ truncated: true })
224
- : (text ?? '');
225
- await row.save();
242
+ await authority.complete(tenantId, id, error === undefined, error === undefined ? result : { error });
226
243
  publish({
227
244
  type: 'approval',
228
245
  id,
@@ -233,7 +250,9 @@ export async function decideApproval(id, approve, decidedBy) {
233
250
  ? { id, status: 'approved', result }
234
251
  : { id, status: 'failed', error };
235
252
  }
236
- finally {
237
- inFlight.delete(id);
253
+ catch (error) {
254
+ if (error instanceof OwnerApprovalError && error.code === 'conflict')
255
+ throw new Error('approval is already decided');
256
+ throw error;
238
257
  }
239
258
  }
@@ -1,16 +1,24 @@
1
- import { type Command, type ExecuteResult } from '../../engine/index.ts';
1
+ import { type Command } from '../../engine/index.ts';
2
2
  import { type PendingApproval } from './approvals.ts';
3
- export type BuilderResult = ExecuteResult | PendingApproval;
4
- /** Run a command for `author`. Never passes `approved` (see approvals.ts). */
5
- export declare function runCommand(command: Command, author: string): Promise<BuilderResult>;
6
- /** Run an app tool; a risky one (`<model>_delete`) becomes an approval. */
7
- export declare function runAppTool(name: string, args: Record<string, unknown>, author: string): Promise<import("../../engine/host.ts").ToolResult | PendingApproval>;
8
- /** Pass a request to the app's generated REST; a DELETE becomes an approval. */
3
+ import { type PublicExecuteResult } from './results.ts';
4
+ import type { Target } from './target.ts';
5
+ export type BuilderResult = PublicExecuteResult | PendingApproval;
6
+ /** Run a command for `author` on `target`. Never passes `approved` (see approvals.ts). */
7
+ export declare function runCommand(command: Command, author: string, target: Target): Promise<BuilderResult>;
8
+ /** Run an app tool on `target`; a risky one (`<model>_delete`) on live becomes an approval. */
9
+ export declare function runAppTool(name: string, args: Record<string, unknown>, author: string, target: Target): Promise<import("../../engine/host.ts").ToolResult | PendingApproval>;
10
+ /** Pass a request to the app's generated REST on `target`; a live DELETE becomes an approval. */
9
11
  export declare function runRest(req: {
10
12
  method: string;
11
13
  url: string;
12
14
  body?: string;
13
- }, author: string): Promise<import("../../engine/host.ts").RestResult | PendingApproval>;
15
+ }, author: string, target: Target): Promise<import("../../engine/host.ts").RestResult | PendingApproval>;
14
16
  export declare function isPending(x: unknown): x is PendingApproval;
15
- /** Delete a record: always an approval (nothing is deleted until decided). */
16
- export declare function requestRecordDelete(model: string, id: string, author: string): Promise<PendingApproval>;
17
+ /**
18
+ * Delete a record. On live: always an approval (nothing is deleted until
19
+ * decided). On the draft: deleted at once (a draft's records are throw-away
20
+ * test data and its host approves its own risky changes).
21
+ */
22
+ export declare function requestRecordDelete(model: string, id: string, author: string, target: Target): Promise<PendingApproval | {
23
+ deleted: string;
24
+ }>;
@@ -1,31 +1,53 @@
1
1
  // Builder operations: run an engine command, or, if it is risky, turn it into
2
2
  // an Approval and return `pending_approval` without changing anything.
3
- import { ApprovalRequiredError, } from "../../engine/index.js";
3
+ //
4
+ // Every function takes the copy it acts on (`target`), named by the caller from
5
+ // the request. There is no default. On the LIVE copy a risky change becomes an
6
+ // Approval; on the DRAFT copy the draft host approves its own risky changes
7
+ // (`autoApprove`, engine-enforced to draft hosts only), so none is asked.
8
+ import { ApprovalRequiredError, EngineError, isStructuralCommand, } from "../../engine/index.js";
4
9
  import { requestApproval, } from "./approvals.js";
5
- import { getHost } from "./host.js";
6
- /** Run a command for `author`. Never passes `approved` (see approvals.ts). */
7
- export async function runCommand(command, author) {
8
- const host = await getHost();
10
+ import { getDraftSlot, hostFor } from "./host.js";
11
+ import { publicChangeResult } from "./results.js";
12
+ /** The refusal for a structural request that names the live copy while a draft is open. */
13
+ async function refuseLiveStructure() {
14
+ const slot = await getDraftSlot();
15
+ throw new EngineError(slot
16
+ ? `The draft "${slot.name}" is open, so the live app's structure is frozen. Make this change in the draft (target=draft), or publish or discard the draft first.`
17
+ : 'The live app is frozen.', 'refused');
18
+ }
19
+ /** Run a command for `author` on `target`. Never passes `approved` (see approvals.ts). */
20
+ export async function runCommand(command, author, target) {
21
+ const host = await hostFor(target);
22
+ if (target === 'live' && host.frozen && isStructuralCommand(command))
23
+ await refuseLiveStructure();
9
24
  try {
10
- return await host.execute(command, { author });
25
+ return publicChangeResult(await host.execute(command, { author }));
11
26
  }
12
27
  catch (e) {
13
28
  if (e instanceof ApprovalRequiredError) {
29
+ // Only the live host asks (the draft's autoApprove never throws this).
14
30
  // An undo is approved for the version it was planned against: if the app
15
31
  // moves on first, approving it must not undo something else.
16
32
  const version = e.plan.preview
17
33
  ?.version;
18
- const pinned = command.type === 'undo' && typeof version === 'number'
19
- ? { type: 'undo', version }
20
- : command;
34
+ let pinned = command;
35
+ if (command.type === 'undo' && typeof version === 'number')
36
+ pinned = { type: 'undo', version };
37
+ else if (command.type === 'undoTo')
38
+ pinned = {
39
+ type: 'undoTo',
40
+ version: command.version,
41
+ from: host.version,
42
+ };
21
43
  return requestApproval({ kind: 'command', command: pinned }, e.plan, author);
22
44
  }
23
45
  throw e;
24
46
  }
25
47
  }
26
- /** Run an app tool; a risky one (`<model>_delete`) becomes an approval. */
27
- export async function runAppTool(name, args, author) {
28
- const host = await getHost();
48
+ /** Run an app tool on `target`; a risky one (`<model>_delete`) on live becomes an approval. */
49
+ export async function runAppTool(name, args, author, target) {
50
+ const host = await hostFor(target);
29
51
  try {
30
52
  return await host.callAppTool(name, args);
31
53
  }
@@ -35,9 +57,9 @@ export async function runAppTool(name, args, author) {
35
57
  throw e;
36
58
  }
37
59
  }
38
- /** Pass a request to the app's generated REST; a DELETE becomes an approval. */
39
- export async function runRest(req, author) {
40
- const host = await getHost();
60
+ /** Pass a request to the app's generated REST on `target`; a live DELETE becomes an approval. */
61
+ export async function runRest(req, author, target) {
62
+ const host = await hostFor(target);
41
63
  try {
42
64
  return await host.rest(req);
43
65
  }
@@ -59,10 +81,16 @@ export function isPending(x) {
59
81
  typeof x === 'object' &&
60
82
  x.status === 'pending_approval');
61
83
  }
62
- /** Delete a record: always an approval (nothing is deleted until decided). */
63
- export async function requestRecordDelete(model, id, author) {
64
- const host = await getHost();
84
+ /**
85
+ * Delete a record. On live: always an approval (nothing is deleted until
86
+ * decided). On the draft: deleted at once (a draft's records are throw-away
87
+ * test data and its host approves its own risky changes).
88
+ */
89
+ export async function requestRecordDelete(model, id, author, target) {
90
+ const host = await hostFor(target);
65
91
  const record = await host.get(model, id); // 404s for a missing record
92
+ if (target === 'draft')
93
+ return host.remove(model, id);
66
94
  return requestApproval({ kind: 'record-delete', model, id }, {
67
95
  summary: `Delete ${model} record ${id}`,
68
96
  risky: true,