@ikenga/contract 0.9.1 → 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.
@@ -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
@@ -7,6 +7,7 @@ export * from './iyke.js';
7
7
  export * from './artifact.js';
8
8
  export * from './registry.js';
9
9
  export * from './browser.js';
10
+ export * from './pa-actions.js';
10
11
 
11
12
  /** This package's own version. */
12
13
  export const CONTRACT_PACKAGE_VERSION = '0.5.0' as const;
@@ -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
- export const IKENGA_API_VERSION = 2 as const;
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
+ }