@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,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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
|
106
|
+
const created = (await coll.get(saved.id));
|
|
94
107
|
publish({
|
|
95
108
|
type: 'approval',
|
|
96
|
-
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'
|
|
186
|
+
if (row.status !== 'pending')
|
|
169
187
|
throw new Error(`approval is already ${row.status}`);
|
|
170
|
-
|
|
188
|
+
const { authority, tenantId } = await shellApprovalAuthority();
|
|
171
189
|
try {
|
|
172
|
-
const decidedAt = new Date().toISOString();
|
|
173
190
|
if (!approve) {
|
|
174
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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 === '
|
|
199
|
-
result = await
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
|
1
|
+
import { type Command } from '../../engine/index.ts';
|
|
2
2
|
import { type PendingApproval } from './approvals.ts';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export
|
|
6
|
-
/** Run
|
|
7
|
-
export declare function
|
|
8
|
-
/**
|
|
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
|
-
/**
|
|
16
|
-
|
|
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
|
-
|
|
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 {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
:
|
|
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
|
|
27
|
-
export async function runAppTool(name, args, author) {
|
|
28
|
-
const host = await
|
|
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
|
|
39
|
-
export async function runRest(req, author) {
|
|
40
|
-
const host = await
|
|
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
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
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,
|