@ikenga/contract 0.9.0 → 0.10.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/dist/canvas/Canvas.d.ts.map +1 -1
- package/dist/canvas/Canvas.js +14 -2
- package/dist/canvas/Canvas.js.map +1 -1
- package/dist/canvas/types.d.ts +4 -0
- package/dist/canvas/types.d.ts.map +1 -1
- package/dist/canvas/use-pan-zoom.d.ts +2 -0
- package/dist/canvas/use-pan-zoom.d.ts.map +1 -1
- package/dist/canvas/use-pan-zoom.js +42 -3
- package/dist/canvas/use-pan-zoom.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +352 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +67 -1
- package/dist/manifest.js.map +1 -1
- package/dist/pa-actions.d.ts +181 -0
- package/dist/pa-actions.d.ts.map +1 -0
- package/dist/pa-actions.js +92 -0
- package/dist/pa-actions.js.map +1 -0
- package/dist/registry.d.ts +320 -0
- package/dist/registry.d.ts.map +1 -1
- package/package.json +14 -14
- package/src/canvas/Canvas.tsx +25 -1
- package/src/canvas/types.ts +4 -0
- package/src/canvas/use-pan-zoom.ts +43 -2
- package/src/index.ts +1 -0
- package/src/manifest.test.ts +95 -0
- package/src/manifest.ts +76 -1
- package/src/pa-actions.test.ts +92 -0
- package/src/pa-actions.ts +252 -0
- package/dist/engine.d.ts +0 -574
- package/dist/engine.d.ts.map +0 -1
- package/dist/engine.js +0 -85
- package/dist/engine.js.map +0 -1
|
@@ -18,6 +18,8 @@ export interface UsePanZoomArgs {
|
|
|
18
18
|
editMode: boolean;
|
|
19
19
|
/** Re-fit on window resize. Default true. */
|
|
20
20
|
autoFitOnResize?: boolean;
|
|
21
|
+
/** Arrow-key pan + +/- zoom when the canvas root is focused. Default true (WCAG 2.5.7). */
|
|
22
|
+
keyboardPan?: boolean;
|
|
21
23
|
/** Notified whenever the viewport pan/scale changes (controlled mirror). */
|
|
22
24
|
onViewportChange?: (viewport: Viewport) => void;
|
|
23
25
|
/** Escape exits edit mode → parent owns the state flip. */
|
|
@@ -49,6 +51,7 @@ export function usePanZoom(args: UsePanZoomArgs): UsePanZoom {
|
|
|
49
51
|
layout,
|
|
50
52
|
editMode,
|
|
51
53
|
autoFitOnResize = true,
|
|
54
|
+
keyboardPan = true,
|
|
52
55
|
onViewportChange,
|
|
53
56
|
onEditModeChange,
|
|
54
57
|
onSelectionChange,
|
|
@@ -130,7 +133,9 @@ export function usePanZoom(args: UsePanZoomArgs): UsePanZoom {
|
|
|
130
133
|
autoFit(true);
|
|
131
134
|
}, [editMode, autoFit]);
|
|
132
135
|
|
|
133
|
-
// Keyboard — Escape exits edit, Space arms the pan-grab cursor.
|
|
136
|
+
// Keyboard — Escape exits edit, Space arms the pan-grab cursor. When the
|
|
137
|
+
// canvas root itself holds focus, arrow keys pan and +/- zoom: the keyboard
|
|
138
|
+
// alternative to space+drag / pinch-zoom (WCAG 2.5.7 Dragging Movements).
|
|
134
139
|
useEffect(() => {
|
|
135
140
|
const down = (e: KeyboardEvent) => {
|
|
136
141
|
if (e.key === 'Escape' && editMode) {
|
|
@@ -147,6 +152,42 @@ export function usePanZoom(args: UsePanZoomArgs): UsePanZoom {
|
|
|
147
152
|
canvasRef.current.style.cursor = 'grab';
|
|
148
153
|
e.preventDefault();
|
|
149
154
|
}
|
|
155
|
+
// Arrow-pan / +/- zoom only when the canvas surface itself is focused,
|
|
156
|
+
// so widget-internal controls keep their own arrow-key semantics.
|
|
157
|
+
if (keyboardPan && canvasRef.current && document.activeElement === canvasRef.current) {
|
|
158
|
+
// 24 canvas units per step, scaled so the on-screen nudge feels
|
|
159
|
+
// consistent at any zoom level.
|
|
160
|
+
const PAN_STEP = 24;
|
|
161
|
+
const ZOOM_STEP = 0.1;
|
|
162
|
+
switch (e.key) {
|
|
163
|
+
case 'ArrowUp':
|
|
164
|
+
setPan((p) => ({ ...p, y: p.y + PAN_STEP * p.scale }));
|
|
165
|
+
e.preventDefault();
|
|
166
|
+
break;
|
|
167
|
+
case 'ArrowDown':
|
|
168
|
+
setPan((p) => ({ ...p, y: p.y - PAN_STEP * p.scale }));
|
|
169
|
+
e.preventDefault();
|
|
170
|
+
break;
|
|
171
|
+
case 'ArrowLeft':
|
|
172
|
+
setPan((p) => ({ ...p, x: p.x + PAN_STEP * p.scale }));
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
break;
|
|
175
|
+
case 'ArrowRight':
|
|
176
|
+
setPan((p) => ({ ...p, x: p.x - PAN_STEP * p.scale }));
|
|
177
|
+
e.preventDefault();
|
|
178
|
+
break;
|
|
179
|
+
default:
|
|
180
|
+
// '=' shares the key with '+'; both Equal and NumpadAdd zoom in.
|
|
181
|
+
if (e.code === 'Equal' || e.code === 'NumpadAdd') {
|
|
182
|
+
setPan((p) => ({ ...p, scale: Math.min(2, p.scale + ZOOM_STEP) }));
|
|
183
|
+
e.preventDefault();
|
|
184
|
+
} else if (e.code === 'Minus' || e.code === 'NumpadSubtract') {
|
|
185
|
+
setPan((p) => ({ ...p, scale: Math.max(0.2, p.scale - ZOOM_STEP) }));
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
}
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
150
191
|
};
|
|
151
192
|
const up = (e: KeyboardEvent) => {
|
|
152
193
|
if (e.code === 'Space') {
|
|
@@ -160,7 +201,7 @@ export function usePanZoom(args: UsePanZoomArgs): UsePanZoom {
|
|
|
160
201
|
window.removeEventListener('keydown', down);
|
|
161
202
|
window.removeEventListener('keyup', up);
|
|
162
203
|
};
|
|
163
|
-
}, [editMode, onEditModeChange, onSelectionChange]);
|
|
204
|
+
}, [editMode, keyboardPan, onEditModeChange, onSelectionChange]);
|
|
164
205
|
|
|
165
206
|
const beginPan = useCallback((clientX: number, clientY: number) => {
|
|
166
207
|
const p = panRef.current;
|
package/src/index.ts
CHANGED
package/src/manifest.test.ts
CHANGED
|
@@ -2,9 +2,14 @@ import { test } from 'node:test';
|
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
HttpCapabilitySchema,
|
|
6
|
+
IKENGA_API_VERSION,
|
|
7
|
+
InvokeCapabilitySchema,
|
|
5
8
|
ManifestSchema,
|
|
9
|
+
NamedSecretSchema,
|
|
6
10
|
RequiresEntrySchema,
|
|
7
11
|
RequireSourceSchema,
|
|
12
|
+
SecretsCapabilitySchema,
|
|
8
13
|
} from './manifest.js';
|
|
9
14
|
|
|
10
15
|
// ─── WP-11 — `requires` field (ADR-015 §3) ──────────────────────────────────
|
|
@@ -82,6 +87,96 @@ test('Manifest: requires defaults to [] when absent (pre-Phase-4 manifest)', ()
|
|
|
82
87
|
assert.deepEqual(m.requires, []);
|
|
83
88
|
});
|
|
84
89
|
|
|
90
|
+
// ─── WP-01 — trusted-cap tier (ADR-017): http / secrets / invoke + signature ─
|
|
91
|
+
|
|
92
|
+
test('IKENGA_API_VERSION is 3 (ADR-017 soft bump)', () => {
|
|
93
|
+
assert.equal(IKENGA_API_VERSION, 3);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('HttpCapability: auth_header defaults to Authorization; auth_secret optional', () => {
|
|
97
|
+
const h = HttpCapabilitySchema.parse({});
|
|
98
|
+
assert.equal(h.auth_header, 'Authorization');
|
|
99
|
+
assert.equal(h.auth_secret, undefined);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('HttpCapability: .strict rejects unknown field (mirrors Rust deny_unknown_fields)', () => {
|
|
103
|
+
assert.throws(() => HttpCapabilitySchema.parse({ bogus: true }));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('NamedSecret: full shape parses; required defaults false', () => {
|
|
107
|
+
const s = NamedSecretSchema.parse({ name: 'twenty', vault_key: 'TWENTY_API_KEY' });
|
|
108
|
+
assert.equal(s.required, false);
|
|
109
|
+
const r = NamedSecretSchema.parse({
|
|
110
|
+
name: 'k',
|
|
111
|
+
vault_key: 'K',
|
|
112
|
+
required: true,
|
|
113
|
+
format: 'bearer',
|
|
114
|
+
});
|
|
115
|
+
assert.equal(r.required, true);
|
|
116
|
+
assert.equal(r.format, 'bearer');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('NamedSecret: .strict rejects unknown field', () => {
|
|
120
|
+
assert.throws(() =>
|
|
121
|
+
NamedSecretSchema.parse({ name: 'k', vault_key: 'K', bogus: true }),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('SecretsCapability: declarations default to []', () => {
|
|
126
|
+
const s = SecretsCapabilitySchema.parse({});
|
|
127
|
+
assert.deepEqual(s.declarations, []);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('InvokeCapability: empty object parses; commands defaults to [] (presence gate)', () => {
|
|
131
|
+
assert.deepEqual(InvokeCapabilitySchema.parse({}), { commands: [] });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('InvokeCapability (D-06): commands allowlist parses + survives', () => {
|
|
135
|
+
const i = InvokeCapabilitySchema.parse({ commands: ['pa_actions_commit', 'pa_actions_reject'] });
|
|
136
|
+
assert.deepEqual(i.commands, ['pa_actions_commit', 'pa_actions_reject']);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('InvokeCapability: .strict rejects unknown field (mirrors Rust deny_unknown_fields)', () => {
|
|
140
|
+
assert.throws(() => InvokeCapabilitySchema.parse({ commands: [], bogus: true }));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('Manifest: fully-populated trusted-cap manifest round-trips (G-MANIFEST DoD)', () => {
|
|
144
|
+
// The contract-side half of the round-trip parse-fixture: a manifest carrying
|
|
145
|
+
// ALL FOUR new fields — top-level `signature`, `capabilities.http` (with
|
|
146
|
+
// auth_secret + custom header), `capabilities.secrets` (with a declaration),
|
|
147
|
+
// and the presence-gate `capabilities.invoke` — parses and the values survive.
|
|
148
|
+
const m = ManifestSchema.parse({
|
|
149
|
+
...BASE,
|
|
150
|
+
ikenga_api: '3',
|
|
151
|
+
signature: 'ed25519:Zm9vYmFyYmF6',
|
|
152
|
+
permissions: { net: ['https://api.twenty.com/'], 'vault.keys': ['TWENTY_API_KEY'] },
|
|
153
|
+
capabilities: {
|
|
154
|
+
http: { auth_secret: 'twenty', auth_header: 'X-Api-Key' },
|
|
155
|
+
secrets: {
|
|
156
|
+
declarations: [
|
|
157
|
+
{ name: 'twenty', vault_key: 'TWENTY_API_KEY', required: true, format: 'bearer' },
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
invoke: { commands: ['pa_actions_commit'] },
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
assert.equal(m.signature, 'ed25519:Zm9vYmFyYmF6');
|
|
164
|
+
assert.equal(m.capabilities?.http?.auth_secret, 'twenty');
|
|
165
|
+
assert.equal(m.capabilities?.http?.auth_header, 'X-Api-Key');
|
|
166
|
+
assert.equal(m.capabilities?.secrets?.declarations.length, 1);
|
|
167
|
+
assert.equal(m.capabilities?.secrets?.declarations[0].vault_key, 'TWENTY_API_KEY');
|
|
168
|
+
assert.equal(m.capabilities?.secrets?.declarations[0].required, true);
|
|
169
|
+
assert.ok(m.capabilities?.invoke !== undefined);
|
|
170
|
+
// D-06: the invoke command allowlist survives the round-trip.
|
|
171
|
+
assert.deepEqual(m.capabilities?.invoke?.commands, ['pa_actions_commit']);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('Manifest: api=1 manifest without new fields parses (back-compat)', () => {
|
|
175
|
+
const m = ManifestSchema.parse({ ...BASE });
|
|
176
|
+
assert.equal(m.signature, undefined);
|
|
177
|
+
assert.equal(m.capabilities, undefined);
|
|
178
|
+
});
|
|
179
|
+
|
|
85
180
|
test('Manifest: retired bundling fields are no longer part of the type (WP-17)', () => {
|
|
86
181
|
// ADR-015 decision 4: `skills`/`commands`/`agents` were hard-retired from the
|
|
87
182
|
// schema (lockstep with the Rust `deny_unknown_fields` Manifest, which REJECTS
|
package/src/manifest.ts
CHANGED
|
@@ -13,7 +13,10 @@ import { EngineProvidesSchema } from './engine/index.js';
|
|
|
13
13
|
|
|
14
14
|
// v2 (WP-05): added capabilities.sqlite + permissions["sqlite.tables"];
|
|
15
15
|
// permissions["supabase.tables"] kept as a compat alias for api=1 manifests.
|
|
16
|
-
|
|
16
|
+
// v3 (ADR-017): added capabilities.http / .secrets / .invoke (trusted-cap tier)
|
|
17
|
+
// + top-level optional `signature`. All additive; api=1/2 manifests parse
|
|
18
|
+
// unchanged. Elevated caps are inert unless the pkg is trusted.
|
|
19
|
+
export const IKENGA_API_VERSION = 3 as const;
|
|
17
20
|
export const IKENGA_API_MIN_SUPPORTED = 1 as const;
|
|
18
21
|
|
|
19
22
|
// ---------- Sub-schemas ----------
|
|
@@ -183,6 +186,65 @@ export type WebviewCapability = z.infer<typeof WebviewCapabilitySchema>;
|
|
|
183
186
|
export const AgentOpsCapabilitySchema = z.object({});
|
|
184
187
|
export type AgentOpsCapability = z.infer<typeof AgentOpsCapabilitySchema>;
|
|
185
188
|
|
|
189
|
+
/** host.fetch capability (ADR-017, TRUSTED-only). Host-mediated HTTP proxy;
|
|
190
|
+
* the shell makes the request and attaches auth from Stronghold — the key
|
|
191
|
+
* never enters the iframe. URL allowlist = `permissions.net`. Mirrors
|
|
192
|
+
* `HttpCapability` in `shell/src-tauri/src/pkg/manifest.rs`. */
|
|
193
|
+
export const HttpCapabilitySchema = z
|
|
194
|
+
.object({
|
|
195
|
+
/** Name of a `capabilities.secrets` declaration whose resolved value the
|
|
196
|
+
* shell attaches as the auth header. Omit = unauthenticated proxy. */
|
|
197
|
+
auth_secret: z.string().optional(),
|
|
198
|
+
/** Header name for the auth secret. Default "Authorization". */
|
|
199
|
+
auth_header: z.string().default('Authorization'),
|
|
200
|
+
})
|
|
201
|
+
.strict();
|
|
202
|
+
export type HttpCapability = z.infer<typeof HttpCapabilitySchema>;
|
|
203
|
+
|
|
204
|
+
/** One named-secret declaration. `vault_key` is resolved host-side and never
|
|
205
|
+
* reaches the iframe; the iframe sees only `hostContext.secrets[name]`.
|
|
206
|
+
* Mirrors `NamedSecret` in `shell/src-tauri/src/pkg/manifest.rs`. */
|
|
207
|
+
export const NamedSecretSchema = z
|
|
208
|
+
.object({
|
|
209
|
+
name: z.string(),
|
|
210
|
+
/** Vault key (must be within permissions["vault.keys"]). Host-only. */
|
|
211
|
+
vault_key: z.string(),
|
|
212
|
+
/** When true, mount fails if the key is missing (Supabase `required`). */
|
|
213
|
+
required: z.boolean().default(false),
|
|
214
|
+
/** Optional value-format hint: "jwt" | "bearer" | "raw". String, not enum. */
|
|
215
|
+
format: z.string().optional(),
|
|
216
|
+
})
|
|
217
|
+
.strict();
|
|
218
|
+
export type NamedSecret = z.infer<typeof NamedSecretSchema>;
|
|
219
|
+
|
|
220
|
+
/** Named-secret injection capability (ADR-017, TRUSTED-only). Generalizes the
|
|
221
|
+
* Supabase hostContext handshake. Mirrors `SecretsCapability` in
|
|
222
|
+
* `shell/src-tauri/src/pkg/manifest.rs`. */
|
|
223
|
+
export const SecretsCapabilitySchema = z
|
|
224
|
+
.object({
|
|
225
|
+
declarations: z.array(NamedSecretSchema).default([]),
|
|
226
|
+
})
|
|
227
|
+
.strict();
|
|
228
|
+
export type SecretsCapability = z.infer<typeof SecretsCapabilitySchema>;
|
|
229
|
+
|
|
230
|
+
/** Scoped Tauri invoke passthrough (ADR-017, TRUSTED-only). Presence gates
|
|
231
|
+
* `host.invoke`; `commands` is the named-command allowlist matched (glob) by
|
|
232
|
+
* `permissions_check::check_shell_execute` against the `host.invoke` command.
|
|
233
|
+
*
|
|
234
|
+
* D-06: the allowlist is `invoke`'s OWN field, NOT `permissions["shell.execute"]`.
|
|
235
|
+
* Reusing `shell.execute` would trip `requires_trust` → the pkg only ever reaches
|
|
236
|
+
* user-`Granted`, never `AutoTrusted`, so `is_trusted_for_elevated()` is false and
|
|
237
|
+
* `host.invoke` would always deny. Keeping the allowlist here lets a signed/builtin
|
|
238
|
+
* pkg declare invokable commands while leaving `shell.execute` empty → AutoTrusted →
|
|
239
|
+
* elevated. POLICY: named commands only, never `*` (not a general shell).
|
|
240
|
+
* Mirrors `InvokeCapability` in `shell/src-tauri/src/pkg/manifest.rs`. */
|
|
241
|
+
export const InvokeCapabilitySchema = z
|
|
242
|
+
.object({
|
|
243
|
+
commands: z.array(z.string()).default([]),
|
|
244
|
+
})
|
|
245
|
+
.strict();
|
|
246
|
+
export type InvokeCapability = z.infer<typeof InvokeCapabilitySchema>;
|
|
247
|
+
|
|
186
248
|
export const WindowBlockSchema = z.object({
|
|
187
249
|
label: z.string(),
|
|
188
250
|
url: z.string(),
|
|
@@ -283,6 +345,12 @@ export const ManifestSchema = z.object({
|
|
|
283
345
|
sqlite: SqliteCapabilitySchema.optional(),
|
|
284
346
|
webview: WebviewCapabilitySchema.optional(),
|
|
285
347
|
agentOps: AgentOpsCapabilitySchema.optional(),
|
|
348
|
+
/** host.fetch proxy (ADR-017). Inert unless the pkg is trusted. */
|
|
349
|
+
http: HttpCapabilitySchema.optional(),
|
|
350
|
+
/** Named-secret injection (ADR-017). Inert unless trusted. */
|
|
351
|
+
secrets: SecretsCapabilitySchema.optional(),
|
|
352
|
+
/** host.invoke passthrough (ADR-017). Inert unless trusted. */
|
|
353
|
+
invoke: InvokeCapabilitySchema.optional(),
|
|
286
354
|
}).optional(),
|
|
287
355
|
|
|
288
356
|
/**
|
|
@@ -310,6 +378,13 @@ export const ManifestSchema = z.object({
|
|
|
310
378
|
* (`pkg/manifest.rs`) — keep in lockstep (`deny_unknown_fields`).
|
|
311
379
|
*/
|
|
312
380
|
requires: z.array(RequiresEntrySchema).default([]),
|
|
381
|
+
|
|
382
|
+
/** Optional ed25519 signature over the normalized manifest JSON (sort keys,
|
|
383
|
+
* strip `signature` before signing). Format `"ed25519:<base64>"`. Present
|
|
384
|
+
* on notarized registry pkgs; verified at install against the publisher key
|
|
385
|
+
* the signed registry index named. Absent → pkg isn't trusted (no elevated
|
|
386
|
+
* caps). Mirrors `signature: Option<String>` on the Rust `Manifest`. */
|
|
387
|
+
signature: z.string().optional(),
|
|
313
388
|
});
|
|
314
389
|
|
|
315
390
|
export type Manifest = z.infer<typeof ManifestSchema>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { type ApproveGateMeta, type DraftItem, draftPreview, fromDraftItem } from './pa-actions.js';
|
|
4
|
+
|
|
5
|
+
const META: ApproveGateMeta = {
|
|
6
|
+
actionId: 'com.ikenga.skill-mail/reply',
|
|
7
|
+
actionName: 'Reply',
|
|
8
|
+
agent: 'PA',
|
|
9
|
+
model: 'Opus 4.7',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function item(over: Partial<DraftItem> = {}): DraftItem {
|
|
13
|
+
return {
|
|
14
|
+
id: 'd1',
|
|
15
|
+
recipient: 'Valentim de Carvalho',
|
|
16
|
+
recipientEmail: 'valentim@valentimdc.pt',
|
|
17
|
+
subject: 'Re: Catalog import',
|
|
18
|
+
body: 'Olá Valentim,\n\nRecebido. Vou processar agora.',
|
|
19
|
+
channel: 'smtp',
|
|
20
|
+
senderAddress: 'chinedum@royalti.io',
|
|
21
|
+
fromProvider: 'SMTP · Fastmail',
|
|
22
|
+
scheduledIso: null,
|
|
23
|
+
scheduledLabel: 'today',
|
|
24
|
+
...over,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Anchor everything to LOCAL time so the day-bucketing assertions are
|
|
29
|
+
// timezone-independent (new Date(y, m, d, h) is local-time construction).
|
|
30
|
+
const NOW = new Date(2026, 5, 7, 11, 0, 0).getTime(); // 11:00 local, 2026-06-07
|
|
31
|
+
const TODAY_ISO = new Date(2026, 5, 7, 17, 0, 0).toISOString(); // same local day, future
|
|
32
|
+
const OVERDUE_ISO = new Date(2026, 5, 5, 9, 0, 0).toISOString(); // two days earlier
|
|
33
|
+
|
|
34
|
+
test('fromDraftItem maps producer fields + meta', () => {
|
|
35
|
+
const d = fromDraftItem(item(), META, NOW);
|
|
36
|
+
assert.equal(d.id, 'd1');
|
|
37
|
+
assert.equal(d.agent, 'PA');
|
|
38
|
+
assert.equal(d.model, 'Opus 4.7');
|
|
39
|
+
assert.equal(d.channel, 'smtp');
|
|
40
|
+
assert.equal(d.consequence.channel, 'SMTP');
|
|
41
|
+
assert.equal(d.consequence.target, 'Valentim de Carvalho');
|
|
42
|
+
assert.equal(d.consequence.recipients, 1);
|
|
43
|
+
assert.equal(d.consequence.undoMs, 10_000);
|
|
44
|
+
assert.equal(d.everEdited, false);
|
|
45
|
+
assert.equal(d.cold, false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('fromDraftItem buckets an overdue draft', () => {
|
|
49
|
+
const d = fromDraftItem(item({ scheduledIso: OVERDUE_ISO }), META, NOW);
|
|
50
|
+
assert.equal(d.overdue, true);
|
|
51
|
+
assert.equal(d.timeVariant, 'is-overdue');
|
|
52
|
+
assert.equal(d.section, 'Overdue');
|
|
53
|
+
assert.equal(d.status, 'overdue');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('fromDraftItem buckets a same-day draft as today', () => {
|
|
57
|
+
const d = fromDraftItem(item({ scheduledIso: TODAY_ISO }), META, NOW);
|
|
58
|
+
assert.equal(d.overdue, false);
|
|
59
|
+
assert.equal(d.timeVariant, 'is-today');
|
|
60
|
+
assert.equal(d.section, 'Today');
|
|
61
|
+
assert.equal(d.status, 'awaiting');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('an explicit section overrides time bucketing', () => {
|
|
65
|
+
const d = fromDraftItem(item({ scheduledIso: OVERDUE_ISO, section: 'This week' }), META, NOW);
|
|
66
|
+
assert.equal(d.section, 'This week');
|
|
67
|
+
assert.equal(d.overdue, true); // overdue flag still reflects the clock
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('sequence recipient count flows into the consequence', () => {
|
|
71
|
+
const d = fromDraftItem(
|
|
72
|
+
item({
|
|
73
|
+
recipients: 388,
|
|
74
|
+
sequence: { name: 'L5 Winback', step: 3, total: 5, recipients: 388 },
|
|
75
|
+
}),
|
|
76
|
+
META,
|
|
77
|
+
NOW
|
|
78
|
+
);
|
|
79
|
+
assert.equal(d.consequence.recipients, 388);
|
|
80
|
+
assert.equal(d.sequence?.step, 3);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('undoMs falls back to 10s when meta omits it', () => {
|
|
84
|
+
const d = fromDraftItem(item(), { ...META, undoMs: undefined }, NOW);
|
|
85
|
+
assert.equal(d.consequence.undoMs, 10_000);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('draftPreview collapses whitespace and truncates', () => {
|
|
89
|
+
assert.equal(draftPreview('a b\n\nc'), 'a b c');
|
|
90
|
+
assert.ok(draftPreview('x'.repeat(300)).length <= 160);
|
|
91
|
+
assert.ok(draftPreview('x'.repeat(300)).endsWith('…'));
|
|
92
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approve-gate draft types — the run-then-pause (`ux_mode: approve`) payload.
|
|
3
|
+
*
|
|
4
|
+
* An approve-aware action does its work, then — instead of performing the
|
|
5
|
+
* external side effect — hands the shell a batch of `DraftItem`s plus an
|
|
6
|
+
* `ApproveGateMeta` via the (forthcoming) `host.paActionsPause` verb. The shell
|
|
7
|
+
* persists them to `pa_action_drafts` (ikenga.db), emits `pa-action-paused`, and
|
|
8
|
+
* mounts the approve-gate panel
|
|
9
|
+
* (`shell/src/shell/atelier/surfaces/approve-gate-panel.tsx`) at
|
|
10
|
+
* `/outbox/approvals`. The panel renders the rich `PausedDraft` view-model;
|
|
11
|
+
* `fromDraftItem` derives it from the lean producer `DraftItem` so producers
|
|
12
|
+
* stay simple (they never hand-build display state).
|
|
13
|
+
*
|
|
14
|
+
* Scope: `plans/atelier/10-approve-gate-seam.md` (WP-1); behaviour:
|
|
15
|
+
* `07-fe-button-renderer.md` §3.5. Plain-TS by convention (mirrors
|
|
16
|
+
* `host-verbs.ts`) — runtime validation, when needed, lives at the host boundary.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Outbound channel an approve gate commits to. Locked to provider identity. */
|
|
20
|
+
export type DraftChannel = 'smtp' | 'resend' | 'listmonk' | 'buffer';
|
|
21
|
+
|
|
22
|
+
/** Human channel label (display). */
|
|
23
|
+
export const CHANNEL_LABEL: Record<DraftChannel, string> = {
|
|
24
|
+
smtp: 'SMTP',
|
|
25
|
+
resend: 'Resend',
|
|
26
|
+
listmonk: 'Listmonk',
|
|
27
|
+
buffer: 'Buffer',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A draft's position inside an outreach sequence/drip, if any. */
|
|
31
|
+
export interface DraftSequence {
|
|
32
|
+
name: string;
|
|
33
|
+
step: number;
|
|
34
|
+
total: number;
|
|
35
|
+
/** Audience size for this step (e.g. a 388-recipient newsletter section). */
|
|
36
|
+
recipients: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What an approve-aware action emits per draft — the lean, authoritative
|
|
41
|
+
* producer shape. The shell derives the panel's `PausedDraft` from this via
|
|
42
|
+
* `fromDraftItem` (computes preview, time bucketing, consequence), so an action
|
|
43
|
+
* never hand-builds display state.
|
|
44
|
+
*/
|
|
45
|
+
export interface DraftItem {
|
|
46
|
+
id: string;
|
|
47
|
+
/** Recipient display (a person, or a broadcast target like "L5 Winback · 388 recipients"). */
|
|
48
|
+
recipient: string;
|
|
49
|
+
recipientEmail: string | null;
|
|
50
|
+
tenantId?: string | null;
|
|
51
|
+
subject: string;
|
|
52
|
+
body: string;
|
|
53
|
+
channel: DraftChannel;
|
|
54
|
+
/** Sending address, e.g. "chinedum@royalti.io" or "ned@getroyalti.com". */
|
|
55
|
+
senderAddress: string;
|
|
56
|
+
/** Sending route display, e.g. "SMTP · Fastmail". */
|
|
57
|
+
fromProvider: string;
|
|
58
|
+
/** Cold outreach (separate sender domain / reputation). */
|
|
59
|
+
cold?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Audience size for the consequence line; defaults to 1.
|
|
62
|
+
* Kept for display (e.g. "388 recipients"); use `recipientsList` for the actual
|
|
63
|
+
* per-recipient send list on direct-email channels.
|
|
64
|
+
*/
|
|
65
|
+
recipients?: number;
|
|
66
|
+
/**
|
|
67
|
+
* Actual recipient list for direct-email channels (smtp, resend).
|
|
68
|
+
* Adapters iterate this for per-recipient sends + partial-success tracking (DEC-9).
|
|
69
|
+
* Broadcast channels (listmonk, buffer) address a list/channel audience and
|
|
70
|
+
* ignore this field.
|
|
71
|
+
*/
|
|
72
|
+
recipientsList?: { name?: string; email: string }[];
|
|
73
|
+
/**
|
|
74
|
+
* Body content type — adapters set the MIME part accordingly.
|
|
75
|
+
* Defaults to `'text'` when absent.
|
|
76
|
+
*/
|
|
77
|
+
bodyFormat?: 'html' | 'text';
|
|
78
|
+
/** Reply-To header for direct-email channels (smtp, resend). */
|
|
79
|
+
replyTo?: string;
|
|
80
|
+
/** Machine schedule time (ISO) for overdue/today bucketing; null = unscheduled. */
|
|
81
|
+
scheduledIso: string | null;
|
|
82
|
+
/** Row time display, e.g. "scheduled 17:00" / "today" / "2d late". */
|
|
83
|
+
scheduledLabel: string;
|
|
84
|
+
/** Detail-chip schedule display, e.g. "Scheduled · today 17:00". Falls back to scheduledLabel. */
|
|
85
|
+
scheduledChip?: string;
|
|
86
|
+
sequence?: DraftSequence | null;
|
|
87
|
+
deal?: string | null;
|
|
88
|
+
threadCount?: string;
|
|
89
|
+
/** Explicit section bucket; otherwise derived from `scheduledIso`. */
|
|
90
|
+
section?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Partial-success result returned by a `ChannelAdapter.send()` call (DEC-9).
|
|
95
|
+
* `ok` is true when at least one recipient was accepted; `failed` lists
|
|
96
|
+
* per-recipient errors so callers can surface them without dropping success.
|
|
97
|
+
* Shared here so the contract module is the single SoT; the daemon's
|
|
98
|
+
* `lib/channels/types.ts` imports it directly from `@ikenga/contract`.
|
|
99
|
+
*/
|
|
100
|
+
export interface SendResult {
|
|
101
|
+
/** False only when ALL recipients failed (or a batch-level error occurred). */
|
|
102
|
+
ok: boolean;
|
|
103
|
+
/** Provider message/campaign/post id — write around the network call where supported (G-05). */
|
|
104
|
+
externalId?: string;
|
|
105
|
+
/** Addresses the provider accepted (partial success — DEC-9). */
|
|
106
|
+
sent?: string[];
|
|
107
|
+
/** Per-recipient failures for partial-success scenarios. */
|
|
108
|
+
failed?: { email: string; error: string }[];
|
|
109
|
+
/** Batch-level error message (to error_text column) when ok is false. */
|
|
110
|
+
error?: string;
|
|
111
|
+
/** True for transient errors (5xx/429/network) — drives in-worker retry vs permanent fail. */
|
|
112
|
+
retryable?: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Batch-level metadata for one approve-mode run (the gate header + undo window). */
|
|
116
|
+
export interface ApproveGateMeta {
|
|
117
|
+
/** `<pkgId>/<verb>` that produced the batch. */
|
|
118
|
+
actionId: string;
|
|
119
|
+
/** Human action name for the gate header. */
|
|
120
|
+
actionName: string;
|
|
121
|
+
/** Drafting agent label, e.g. "PA" / "CMO" / "CBO". */
|
|
122
|
+
agent: string;
|
|
123
|
+
/** Engine/model that drafted, e.g. "Opus 4.7". */
|
|
124
|
+
model: string;
|
|
125
|
+
/** Undo window before commit, ms. Defaults to 10_000. */
|
|
126
|
+
undoMs?: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The rich view-model the approve-gate panel renders. Kept byte-identical to the
|
|
131
|
+
* panel's prior local definition so the panel just imports it. Derived from a
|
|
132
|
+
* `DraftItem` + `ApproveGateMeta` via `fromDraftItem`; the panel flips
|
|
133
|
+
* `everEdited`/`status` locally as the operator edits.
|
|
134
|
+
*/
|
|
135
|
+
export interface PausedDraft {
|
|
136
|
+
id: string;
|
|
137
|
+
recipient: string;
|
|
138
|
+
recipientEmail: string | null;
|
|
139
|
+
tenantId: string | null;
|
|
140
|
+
subject: string;
|
|
141
|
+
body: string;
|
|
142
|
+
bodyPreview: string;
|
|
143
|
+
channel: DraftChannel;
|
|
144
|
+
agent: string;
|
|
145
|
+
senderAddress: string;
|
|
146
|
+
cold: boolean;
|
|
147
|
+
/**
|
|
148
|
+
* Panel display status. `'failed'` means the send worker exhausted retries
|
|
149
|
+
* and the draft needs operator attention (see `errorMessage` / `attempts`).
|
|
150
|
+
*/
|
|
151
|
+
status: 'awaiting' | 'edited' | 'overdue' | 'failed';
|
|
152
|
+
scheduledAt: string;
|
|
153
|
+
scheduledLabel: string;
|
|
154
|
+
timeVariant: 'is-today' | 'is-overdue' | null;
|
|
155
|
+
overdue: boolean;
|
|
156
|
+
everEdited: boolean;
|
|
157
|
+
section: string;
|
|
158
|
+
sequence: DraftSequence | null;
|
|
159
|
+
fromProvider: string;
|
|
160
|
+
model: string;
|
|
161
|
+
threadCount: string;
|
|
162
|
+
deal: string | null;
|
|
163
|
+
consequence: {
|
|
164
|
+
target: string;
|
|
165
|
+
recipients: number;
|
|
166
|
+
channel: string;
|
|
167
|
+
time: string;
|
|
168
|
+
undoMs: number;
|
|
169
|
+
};
|
|
170
|
+
/** Last error surfaced by the send worker (from pa_action_drafts.error_text). */
|
|
171
|
+
errorMessage?: string;
|
|
172
|
+
/** Number of send attempts so far (from pa_action_drafts.attempts). */
|
|
173
|
+
attempts?: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const PREVIEW_MAX = 160;
|
|
177
|
+
const DEFAULT_UNDO_MS = 10_000;
|
|
178
|
+
|
|
179
|
+
/** Collapse whitespace + truncate `body` for the row preview line. */
|
|
180
|
+
export function draftPreview(body: string, max = PREVIEW_MAX): string {
|
|
181
|
+
const flat = body.replace(/\s+/g, ' ').trim();
|
|
182
|
+
return flat.length > max ? `${flat.slice(0, max - 1).trimEnd()}…` : flat;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function sameLocalDay(a: number, b: number): boolean {
|
|
186
|
+
const da = new Date(a);
|
|
187
|
+
const db = new Date(b);
|
|
188
|
+
return (
|
|
189
|
+
da.getFullYear() === db.getFullYear() &&
|
|
190
|
+
da.getMonth() === db.getMonth() &&
|
|
191
|
+
da.getDate() === db.getDate()
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Derive the panel's `PausedDraft` from a producer `DraftItem` + the batch
|
|
197
|
+
* `ApproveGateMeta`. `now` is injectable for deterministic tests; it only affects
|
|
198
|
+
* time bucketing (overdue / today / section). Pure + side-effect-free.
|
|
199
|
+
*
|
|
200
|
+
* `errorMessage` and `attempts` are not derivable from a `DraftItem` (they live
|
|
201
|
+
* on the DB row after worker runs); callers that reconstruct a `PausedDraft` from
|
|
202
|
+
* a stored row should set them on the returned object directly.
|
|
203
|
+
*/
|
|
204
|
+
export function fromDraftItem(
|
|
205
|
+
item: DraftItem,
|
|
206
|
+
meta: ApproveGateMeta,
|
|
207
|
+
now: number = Date.now()
|
|
208
|
+
): PausedDraft {
|
|
209
|
+
const scheduledMs = item.scheduledIso ? Date.parse(item.scheduledIso) : null;
|
|
210
|
+
const hasTime = scheduledMs !== null && !Number.isNaN(scheduledMs);
|
|
211
|
+
const overdue = hasTime && (scheduledMs as number) < now;
|
|
212
|
+
const today = hasTime && !overdue && sameLocalDay(scheduledMs as number, now);
|
|
213
|
+
const timeVariant: PausedDraft['timeVariant'] = overdue
|
|
214
|
+
? 'is-overdue'
|
|
215
|
+
: today
|
|
216
|
+
? 'is-today'
|
|
217
|
+
: null;
|
|
218
|
+
const section = item.section ?? (overdue ? 'Overdue' : today ? 'Today' : 'This week');
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
id: item.id,
|
|
222
|
+
recipient: item.recipient,
|
|
223
|
+
recipientEmail: item.recipientEmail,
|
|
224
|
+
tenantId: item.tenantId ?? null,
|
|
225
|
+
subject: item.subject,
|
|
226
|
+
body: item.body,
|
|
227
|
+
bodyPreview: draftPreview(item.body),
|
|
228
|
+
channel: item.channel,
|
|
229
|
+
agent: meta.agent,
|
|
230
|
+
senderAddress: item.senderAddress,
|
|
231
|
+
cold: item.cold ?? false,
|
|
232
|
+
status: overdue ? 'overdue' : 'awaiting',
|
|
233
|
+
scheduledAt: item.scheduledLabel,
|
|
234
|
+
scheduledLabel: item.scheduledChip ?? item.scheduledLabel,
|
|
235
|
+
timeVariant,
|
|
236
|
+
overdue,
|
|
237
|
+
everEdited: false,
|
|
238
|
+
section,
|
|
239
|
+
sequence: item.sequence ?? null,
|
|
240
|
+
fromProvider: item.fromProvider,
|
|
241
|
+
model: meta.model,
|
|
242
|
+
threadCount: item.threadCount ?? '',
|
|
243
|
+
deal: item.deal ?? null,
|
|
244
|
+
consequence: {
|
|
245
|
+
target: item.recipient,
|
|
246
|
+
recipients: item.recipients ?? 1,
|
|
247
|
+
channel: CHANNEL_LABEL[item.channel],
|
|
248
|
+
time: item.scheduledLabel,
|
|
249
|
+
undoMs: meta.undoMs ?? DEFAULT_UNDO_MS,
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|