@ikenga/contract 0.11.0 → 0.13.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/action-frontmatter.d.ts +389 -0
- package/dist/action-frontmatter.d.ts.map +1 -0
- package/dist/action-frontmatter.js +215 -0
- package/dist/action-frontmatter.js.map +1 -0
- package/dist/artifact.d.ts +110 -110
- package/dist/browser.d.ts +2 -2
- package/dist/engine/adapter.d.ts +6 -6
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +175 -150
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +5 -0
- package/dist/manifest.js.map +1 -1
- package/dist/pa-actions.d.ts +35 -0
- package/dist/pa-actions.d.ts.map +1 -1
- package/dist/pa-actions.js.map +1 -1
- package/dist/registry.d.ts +301 -285
- package/dist/registry.d.ts.map +1 -1
- package/dist/window.d.ts +128 -0
- package/dist/window.d.ts.map +1 -0
- package/dist/window.js +86 -0
- package/dist/window.js.map +1 -0
- package/package.json +11 -2
- package/src/action-frontmatter.test.ts +219 -0
- package/src/action-frontmatter.ts +250 -0
- package/src/index.ts +2 -0
- package/src/manifest.ts +5 -0
- package/src/pa-actions.ts +38 -0
- package/src/window.test.ts +70 -0
- package/src/window.ts +100 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Ikenga skill-action frontmatter schema — the source-of-truth shape for an
|
|
2
|
+
// Atelier skill *action*'s YAML frontmatter (the block between the leading
|
|
3
|
+
// `---` fences of an `actions/*.md` file).
|
|
4
|
+
//
|
|
5
|
+
// The Rust loader in `royalti-io/ikenga` at
|
|
6
|
+
// `src-tauri/src/pkg/skill_actions.rs` mirrors this schema. Field changes MUST
|
|
7
|
+
// be made in lockstep with that Rust struct — same convention as
|
|
8
|
+
// manifest.ts ↔ manifest.rs.
|
|
9
|
+
//
|
|
10
|
+
// Every field below is justified in plans/atelier/06-skill-action-contract.md.
|
|
11
|
+
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Identity & taxonomy (G-TAXONOMY)
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The eight stateful domains plus the one stateless generic skill.
|
|
20
|
+
* `skill-core` is the ONLY legal `depends_on` target (one-way edge, G-04/E-14).
|
|
21
|
+
*/
|
|
22
|
+
export const DomainEnum = z.enum([
|
|
23
|
+
'tasks',
|
|
24
|
+
'mail',
|
|
25
|
+
'outbound',
|
|
26
|
+
'sales',
|
|
27
|
+
'finance',
|
|
28
|
+
'content',
|
|
29
|
+
'research',
|
|
30
|
+
'strategy',
|
|
31
|
+
'skill-core',
|
|
32
|
+
]);
|
|
33
|
+
export type Domain = z.infer<typeof DomainEnum>;
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// UX modes (R5, E-11) — exactly five
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How an action presents itself to the operator.
|
|
41
|
+
* - confirm : preview the planned effect, single yes/no, then run.
|
|
42
|
+
* - silent : run with no prompt (still subject to capability scopes).
|
|
43
|
+
* - form : collect `inputs_schema` from the operator before running.
|
|
44
|
+
* - streaming : run with live token/log streaming surfaced in the dock.
|
|
45
|
+
* - approve : run-THEN-pause on a produced draft; operator approves/edits
|
|
46
|
+
* /rejects the artifact before any external side effect commits
|
|
47
|
+
* (the draft-review gate, E-11).
|
|
48
|
+
*/
|
|
49
|
+
export const UxModeEnum = z.enum([
|
|
50
|
+
'confirm',
|
|
51
|
+
'silent',
|
|
52
|
+
'form',
|
|
53
|
+
'streaming',
|
|
54
|
+
'approve',
|
|
55
|
+
]);
|
|
56
|
+
export type UxMode = z.infer<typeof UxModeEnum>;
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Run binding (R5, G-01) — how the action actually executes
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/** chat_prompt: hand a templated prompt to the engine in the dock chat. */
|
|
63
|
+
const ChatPromptRun = z.object({
|
|
64
|
+
kind: z.literal('chat_prompt'),
|
|
65
|
+
// Prompt template; may interpolate validated `inputs` via {{var}}.
|
|
66
|
+
prompt: z.string().min(1),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** sidecar: invoke a bundled CLI sidecar by its manifest-declared id. */
|
|
70
|
+
const SidecarRun = z.object({
|
|
71
|
+
kind: z.literal('sidecar'),
|
|
72
|
+
sidecar_id: z.string().min(1),
|
|
73
|
+
// Argv template; entries may interpolate validated `inputs`.
|
|
74
|
+
args: z.array(z.string()).default([]),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** mcp_tool: call a tool on a pkg-declared MCP server. */
|
|
78
|
+
const McpToolRun = z.object({
|
|
79
|
+
kind: z.literal('mcp_tool'),
|
|
80
|
+
server_id: z.string().min(1),
|
|
81
|
+
tool: z.string().min(1),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
export const RunBinding = z.discriminatedUnion('kind', [
|
|
85
|
+
ChatPromptRun,
|
|
86
|
+
SidecarRun,
|
|
87
|
+
McpToolRun,
|
|
88
|
+
]);
|
|
89
|
+
export type RunBinding = z.infer<typeof RunBinding>;
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Triggers (R5) — absorbs the 41 legacy crons. An action may be reachable by
|
|
93
|
+
// more than one trigger; `manual` is the implicit default if none declared.
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
/** Operator-invoked from the command surface / dock. */
|
|
97
|
+
const ManualTrigger = z.object({
|
|
98
|
+
kind: z.literal('manual'),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/** Time-driven. `cron` is a standard 5-field crontab expression. */
|
|
102
|
+
const ScheduleTrigger = z.object({
|
|
103
|
+
kind: z.literal('schedule'),
|
|
104
|
+
cron: z.string().min(1),
|
|
105
|
+
// Optional human label surfaced in the schedule UI.
|
|
106
|
+
label: z.string().optional(),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/** Inbound HTTP trigger routed through the shell bridge. */
|
|
110
|
+
const WebhookTrigger = z.object({
|
|
111
|
+
kind: z.literal('webhook'),
|
|
112
|
+
// Stable path segment; the shell namespaces it under the pkg id.
|
|
113
|
+
path: z.string().min(1),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/** Reacts to an internal shell/domain event by name. */
|
|
117
|
+
const EventTrigger = z.object({
|
|
118
|
+
kind: z.literal('event'),
|
|
119
|
+
event: z.string().min(1),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
export const Trigger = z.discriminatedUnion('kind', [
|
|
123
|
+
ManualTrigger,
|
|
124
|
+
ScheduleTrigger,
|
|
125
|
+
WebhookTrigger,
|
|
126
|
+
EventTrigger,
|
|
127
|
+
]);
|
|
128
|
+
export type Trigger = z.infer<typeof Trigger>;
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Capabilities (G-08, R18/R19) — coarse permission grants an action requires.
|
|
132
|
+
// `sqlite` is the stateful-domain seam: state lives in the local ikenga.db,
|
|
133
|
+
// reached through the host dbQuery (SELECT-only) and dbExec (parameterized
|
|
134
|
+
// mutate) bridges. The *table scope* is NOT declared here — it is declared once
|
|
135
|
+
// at the pkg manifest as permissions["sqlite.tables"] and cross-checked at
|
|
136
|
+
// install time against the generated tables.json (the applied ikenga.db STRICT
|
|
137
|
+
// schema). The frontmatter only asserts "this action touches sqlite".
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
export const CapabilityEnum = z.enum([
|
|
141
|
+
'sqlite', // host.dbQuery / host.dbExec against ikenga.db (R18/R19)
|
|
142
|
+
'mcp', // call declared MCP tools
|
|
143
|
+
'sidecar', // spawn declared sidecars
|
|
144
|
+
'network', // outbound network
|
|
145
|
+
'fs', // host filesystem (scoped by manifest)
|
|
146
|
+
'secrets', // read Stronghold-vaulted secrets
|
|
147
|
+
'chat', // drive the dock chat engine
|
|
148
|
+
]);
|
|
149
|
+
export type Capability = z.infer<typeof CapabilityEnum>;
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Setup lifecycle (D-02, setup-lifecycle decision) — `setup` is an optional,
|
|
153
|
+
// well-known action on EVERY skill. It localises the skill per project by
|
|
154
|
+
// writing ${CLAUDE_PROJECT_DIR}/.atelier/<skill>/manifest.json. It runs IN
|
|
155
|
+
// CHAT, never as a form screen. Two modes:
|
|
156
|
+
// - ai_infer : the agent drafts the instance config from the repo/site, the
|
|
157
|
+
// operator confirms/edits in chat.
|
|
158
|
+
// - interview: the agent asks the operator a scripted set of questions.
|
|
159
|
+
// `template_version` carries a migrate path so an upgraded skill can migrate an
|
|
160
|
+
// older instance file forward.
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
export const SetupModeEnum = z.enum(['ai_infer', 'interview']);
|
|
164
|
+
export type SetupMode = z.infer<typeof SetupModeEnum>;
|
|
165
|
+
|
|
166
|
+
export const SetupSpec = z.object({
|
|
167
|
+
mode: SetupModeEnum,
|
|
168
|
+
// Bumped whenever the instance-file shape changes; drives the migrate path.
|
|
169
|
+
template_version: z.number().int().positive(),
|
|
170
|
+
// For ai_infer: where the agent should look to draft the instance config.
|
|
171
|
+
infer_sources: z.array(z.string()).optional(),
|
|
172
|
+
// For interview: ordered question ids the chat flow walks.
|
|
173
|
+
interview_questions: z.array(z.string()).optional(),
|
|
174
|
+
});
|
|
175
|
+
export type SetupSpec = z.infer<typeof SetupSpec>;
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// ActionFrontmatter — the locked shape.
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
export const ActionFrontmatter = z
|
|
182
|
+
.object({
|
|
183
|
+
// --- identity ---
|
|
184
|
+
/** Stable action id, unique within the skill. kebab-case. */
|
|
185
|
+
name: z
|
|
186
|
+
.string()
|
|
187
|
+
.min(1)
|
|
188
|
+
.regex(/^[a-z][a-z0-9-]*$/, 'name must be kebab-case'),
|
|
189
|
+
/** One-line human description shown in the command surface. */
|
|
190
|
+
description: z.string().min(1),
|
|
191
|
+
/** Owning domain (G-TAXONOMY). */
|
|
192
|
+
domain: DomainEnum,
|
|
193
|
+
|
|
194
|
+
// --- presentation & io ---
|
|
195
|
+
/** How the action presents to the operator (R5/E-11). */
|
|
196
|
+
ux_mode: UxModeEnum,
|
|
197
|
+
/**
|
|
198
|
+
* JSON-Schema describing the action's inputs. Kept as an opaque object here
|
|
199
|
+
* (validated as JSON-Schema at the destination); `form`/`approve` modes
|
|
200
|
+
* render it, `chat_prompt` runs interpolate validated values from it.
|
|
201
|
+
*/
|
|
202
|
+
inputs_schema: z.record(z.string(), z.unknown()).optional(),
|
|
203
|
+
|
|
204
|
+
// --- execution ---
|
|
205
|
+
/** What the action actually does (G-01). */
|
|
206
|
+
run: RunBinding,
|
|
207
|
+
/** How the action can be invoked. Empty ⇒ manual-only. */
|
|
208
|
+
triggers: z.array(Trigger).default([]),
|
|
209
|
+
|
|
210
|
+
// --- dependency & permissions ---
|
|
211
|
+
/**
|
|
212
|
+
* One-way dependency edge. The ONLY legal target is 'skill-core'
|
|
213
|
+
* (G-04/E-14); any other entry is rejected by the lint in
|
|
214
|
+
* 06-skill-action-contract.md §"depends_on lint". Modeled as a literal
|
|
215
|
+
* array so the schema itself refuses non-skill-core targets.
|
|
216
|
+
*/
|
|
217
|
+
depends_on: z.array(z.literal('skill-core')).default([]),
|
|
218
|
+
/** Coarse capability grants this action needs (G-08, R18/R19). */
|
|
219
|
+
requires_capabilities: z.array(CapabilityEnum).default([]),
|
|
220
|
+
|
|
221
|
+
// --- lifecycle ---
|
|
222
|
+
/**
|
|
223
|
+
* Present ONLY on the well-known `setup` action. Omitted on every other
|
|
224
|
+
* action. Refined below.
|
|
225
|
+
*/
|
|
226
|
+
setup: SetupSpec.optional(),
|
|
227
|
+
})
|
|
228
|
+
.strict()
|
|
229
|
+
.superRefine((fm, ctx) => {
|
|
230
|
+
// The `setup` block is allowed iff this IS the setup action.
|
|
231
|
+
if (fm.name === 'setup' && fm.setup === undefined) {
|
|
232
|
+
ctx.addIssue({
|
|
233
|
+
code: z.ZodIssueCode.custom,
|
|
234
|
+
path: ['setup'],
|
|
235
|
+
message: 'the "setup" action must declare a `setup` block',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (fm.name !== 'setup' && fm.setup !== undefined) {
|
|
239
|
+
ctx.addIssue({
|
|
240
|
+
code: z.ZodIssueCode.custom,
|
|
241
|
+
path: ['setup'],
|
|
242
|
+
message: '`setup` block is only valid on the "setup" action',
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
// An action that runs SQL must declare the `sqlite` capability so the
|
|
246
|
+
// pkg-manifest sqlite.tables cross-check has something to bind to.
|
|
247
|
+
// (The reverse — declaring sqlite without using it — is allowed/harmless.)
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
export type ActionFrontmatter = z.infer<typeof ActionFrontmatter>;
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,8 @@ export * from './artifact.js';
|
|
|
8
8
|
export * from './registry.js';
|
|
9
9
|
export * from './browser.js';
|
|
10
10
|
export * from './pa-actions.js';
|
|
11
|
+
export * from './window.js';
|
|
12
|
+
export * from './action-frontmatter.js';
|
|
11
13
|
|
|
12
14
|
/** This package's own version. */
|
|
13
15
|
export const CONTRACT_PACKAGE_VERSION = '0.5.0' as const;
|
package/src/manifest.ts
CHANGED
|
@@ -74,6 +74,11 @@ export const PermissionsSchema = z.object({
|
|
|
74
74
|
* without errors during the transition window. */
|
|
75
75
|
'supabase.tables': z.array(z.string()).default([]),
|
|
76
76
|
'vault.keys': z.array(z.string()).default([]),
|
|
77
|
+
/** Engine scopes exercisable from the pkg iframe. `"invoke"` gates the
|
|
78
|
+
* FE-side host.sendToActiveSession / host.startChatSession verbs
|
|
79
|
+
* (pkgDeclaresScope). Mirrors `Permissions.engine` in the shell's
|
|
80
|
+
* manifest.rs — keep in lockstep. */
|
|
81
|
+
engine: z.array(z.string()).default([]),
|
|
77
82
|
}).default({});
|
|
78
83
|
|
|
79
84
|
export const NavEntrySchema = z.object({
|
package/src/pa-actions.ts
CHANGED
|
@@ -36,6 +36,27 @@ export interface DraftSequence {
|
|
|
36
36
|
recipients: number;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** One sub-post in an X/Twitter thread (X allows ≤4 images per tweet). */
|
|
40
|
+
export interface ThreadPost {
|
|
41
|
+
text: string;
|
|
42
|
+
/** Per-sub-post media URLs (X allows ≤4 images per tweet). */
|
|
43
|
+
imageUrls?: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Media block carried on a Buffer DraftItem. `kind` selects the adapter path:
|
|
48
|
+
* - `'single'` — 0–1 image attached to the post body.
|
|
49
|
+
* - `'carousel'` — 2–10 images (IG ≤10, LI ≤9); adapter uses multi-asset upload.
|
|
50
|
+
* - `'thread'` — ordered sub-posts; adapter posts as N linked Buffer posts (≥2, ≤25).
|
|
51
|
+
*/
|
|
52
|
+
export interface SocialMedia {
|
|
53
|
+
kind: 'single' | 'carousel' | 'thread';
|
|
54
|
+
/** single = 0–1 image URL; carousel = 2–10 (platform-dependent). */
|
|
55
|
+
imageUrls?: string[];
|
|
56
|
+
/** thread-kind only: ordered sub-posts (≥2, ≤25). */
|
|
57
|
+
thread?: ThreadPost[];
|
|
58
|
+
}
|
|
59
|
+
|
|
39
60
|
/**
|
|
40
61
|
* What an approve-aware action emits per draft — the lean, authoritative
|
|
41
62
|
* producer shape. The shell derives the panel's `PausedDraft` from this via
|
|
@@ -88,6 +109,23 @@ export interface DraftItem {
|
|
|
88
109
|
threadCount?: string;
|
|
89
110
|
/** Explicit section bucket; otherwise derived from `scheduledIso`. */
|
|
90
111
|
section?: string;
|
|
112
|
+
// ── Buffer / social fields (mirror of payload_json media contract) ──────────
|
|
113
|
+
/**
|
|
114
|
+
* Resolved Buffer channel id — company vs personal LinkedIn disambiguated at
|
|
115
|
+
* enqueue time (not in the adapter). Only set for `channel: 'buffer'` drafts.
|
|
116
|
+
*/
|
|
117
|
+
channelId?: string;
|
|
118
|
+
/**
|
|
119
|
+
* First comment text for hashtags / blog URL. Avoids Buffer link-preview
|
|
120
|
+
* attachment errors on LinkedIn. Mapped to `metadata.<platform>.firstComment`.
|
|
121
|
+
*/
|
|
122
|
+
firstComment?: string;
|
|
123
|
+
/**
|
|
124
|
+
* Social media block; selects single / carousel / thread adapter path.
|
|
125
|
+
* Kept in lockstep with `payload.schema.ts` `SocialMedia` Zod schema and
|
|
126
|
+
* the worker mirror in `scripts/cron/lib/channels/types.ts`.
|
|
127
|
+
*/
|
|
128
|
+
media?: SocialMedia;
|
|
91
129
|
}
|
|
92
130
|
|
|
93
131
|
/**
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import {
|
|
4
|
+
WINDOW_CONTRACT_VERSION,
|
|
5
|
+
WindowDescriptorSchema,
|
|
6
|
+
WindowEventEnvelopeSchema,
|
|
7
|
+
} from './window.js';
|
|
8
|
+
|
|
9
|
+
// Canonical wire fixtures — the Rust round-trip test
|
|
10
|
+
// (src-tauri/src/window/descriptor.rs + events.rs) asserts the SAME JSON.
|
|
11
|
+
// If these drift, the lockstep contract is broken.
|
|
12
|
+
|
|
13
|
+
const DESCRIPTOR_FIXTURE = {
|
|
14
|
+
label: 'detached-1',
|
|
15
|
+
kind: 'single-surface',
|
|
16
|
+
surface_set: ['chat'],
|
|
17
|
+
project_id: null,
|
|
18
|
+
layout_key: 'detached-1',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const ENVELOPE_FIXTURE = {
|
|
22
|
+
v: 1,
|
|
23
|
+
topic: 'window://opened',
|
|
24
|
+
source_label: 'core',
|
|
25
|
+
target: { kind: 'window', label: 'detached-1' },
|
|
26
|
+
payload: { label: 'detached-1' },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
test('WindowDescriptor parses the canonical fixture and round-trips', () => {
|
|
30
|
+
const parsed = WindowDescriptorSchema.parse(DESCRIPTOR_FIXTURE);
|
|
31
|
+
assert.deepEqual(parsed, DESCRIPTOR_FIXTURE);
|
|
32
|
+
// re-parse the serialized form (round-trip)
|
|
33
|
+
assert.deepEqual(
|
|
34
|
+
WindowDescriptorSchema.parse(JSON.parse(JSON.stringify(parsed))),
|
|
35
|
+
DESCRIPTOR_FIXTURE,
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('WindowDescriptor rejects unknown keys (deny_unknown_fields parity)', () => {
|
|
40
|
+
assert.throws(() =>
|
|
41
|
+
WindowDescriptorSchema.parse({ ...DESCRIPTOR_FIXTURE, rogue: true }),
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('WindowDescriptor applies defaults for surface_set/project_id', () => {
|
|
46
|
+
const parsed = WindowDescriptorSchema.parse({
|
|
47
|
+
label: 'main',
|
|
48
|
+
kind: 'primary',
|
|
49
|
+
layout_key: 'main',
|
|
50
|
+
});
|
|
51
|
+
assert.deepEqual(parsed.surface_set, []);
|
|
52
|
+
assert.equal(parsed.project_id, null);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('WindowEventEnvelope parses the canonical fixture and round-trips', () => {
|
|
56
|
+
const parsed = WindowEventEnvelopeSchema.parse(ENVELOPE_FIXTURE);
|
|
57
|
+
assert.equal(parsed.v, WINDOW_CONTRACT_VERSION);
|
|
58
|
+
assert.deepEqual(
|
|
59
|
+
WindowEventEnvelopeSchema.parse(JSON.parse(JSON.stringify(parsed))),
|
|
60
|
+
ENVELOPE_FIXTURE,
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('WindowEventTarget broadcast variant parses', () => {
|
|
65
|
+
const parsed = WindowEventEnvelopeSchema.parse({
|
|
66
|
+
...ENVELOPE_FIXTURE,
|
|
67
|
+
target: { kind: 'broadcast' },
|
|
68
|
+
});
|
|
69
|
+
assert.equal(parsed.target.kind, 'broadcast');
|
|
70
|
+
});
|
package/src/window.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Multi-window contract — mirrors the Rust source-of-truth in
|
|
2
|
+
// `royalti-io/ikenga` at `src-tauri/src/window/{descriptor,events}.rs`.
|
|
3
|
+
//
|
|
4
|
+
// This is the `G-WINDOW-MODEL` freeze-gate output (plans/multi-window WP-02).
|
|
5
|
+
// It has TWO co-equal halves: the `WindowDescriptor` (what a window IS) and the
|
|
6
|
+
// cross-window event envelope (how windows talk over the shared Rust core).
|
|
7
|
+
// Both are imported by every downstream consumer (the window registry, the thin
|
|
8
|
+
// detached FE entry, Flavor C). Field changes MUST be made in lockstep with the
|
|
9
|
+
// Rust structs; bump WINDOW_CONTRACT_VERSION on any non-additive change.
|
|
10
|
+
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
|
|
13
|
+
/** Envelope + descriptor wire version. Bumped on non-additive change. */
|
|
14
|
+
export const WINDOW_CONTRACT_VERSION = 1 as const;
|
|
15
|
+
|
|
16
|
+
// ---------- WindowDescriptor ----------
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What a window is.
|
|
20
|
+
* - `primary` — the original "main" window (full chrome).
|
|
21
|
+
* - `single-surface` — a thin detached window hosting one surface (Flavor C).
|
|
22
|
+
* - `pane-set` — a torn-off pane group (Flavor A, Phase 2).
|
|
23
|
+
* - `workspace` — a second full workspace window (Flavor B, Phase 3).
|
|
24
|
+
*/
|
|
25
|
+
export const WindowKindSchema = z.enum([
|
|
26
|
+
'primary',
|
|
27
|
+
'single-surface',
|
|
28
|
+
'pane-set',
|
|
29
|
+
'workspace',
|
|
30
|
+
]);
|
|
31
|
+
export type WindowKind = z.infer<typeof WindowKindSchema>;
|
|
32
|
+
|
|
33
|
+
export const WindowDescriptorSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
/** OS window label. "main" for the primary; "detached-<n>" otherwise. */
|
|
36
|
+
label: z.string().min(1),
|
|
37
|
+
kind: WindowKindSchema,
|
|
38
|
+
/** Surface/route ids the window's FE entry mounts. */
|
|
39
|
+
surface_set: z.array(z.string()).default([]),
|
|
40
|
+
/** Project this window is bound to (Flavor B); null = follows primary. */
|
|
41
|
+
project_id: z.string().nullable().default(null),
|
|
42
|
+
/**
|
|
43
|
+
* SQLite `layout_state` partition. A KEY-STRING suffix folded into
|
|
44
|
+
* `scopedKey()` (G-03) — NOT a new column. Usually equals `label`.
|
|
45
|
+
*/
|
|
46
|
+
layout_key: z.string().min(1),
|
|
47
|
+
})
|
|
48
|
+
.strict();
|
|
49
|
+
export type WindowDescriptor = z.infer<typeof WindowDescriptorSchema>;
|
|
50
|
+
|
|
51
|
+
// ---------- Cross-window event envelope ----------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Where a window event goes. The DEFAULT for streaming/data channels is
|
|
55
|
+
* `broadcast` (every window filters its own subscription); only the channels
|
|
56
|
+
* that race (see WINDOW_TARGETED_CHANNELS) carry `window` targeting.
|
|
57
|
+
*/
|
|
58
|
+
export const WindowEventTargetSchema = z.discriminatedUnion('kind', [
|
|
59
|
+
z.object({ kind: z.literal('broadcast') }).strict(),
|
|
60
|
+
z.object({ kind: z.literal('window'), label: z.string().min(1) }).strict(),
|
|
61
|
+
]);
|
|
62
|
+
export type WindowEventTarget = z.infer<typeof WindowEventTargetSchema>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The base shape every cross-window event rides. `payload` is opaque at the
|
|
66
|
+
* contract layer (each topic refines it); the envelope itself is what the
|
|
67
|
+
* registry + windows agree on.
|
|
68
|
+
*/
|
|
69
|
+
export const WindowEventEnvelopeSchema = z
|
|
70
|
+
.object({
|
|
71
|
+
v: z.literal(WINDOW_CONTRACT_VERSION),
|
|
72
|
+
/** Channel name, e.g. "window://opened". */
|
|
73
|
+
topic: z.string().min(1),
|
|
74
|
+
/** Emitting window label, or "core" for the Rust core. */
|
|
75
|
+
source_label: z.string().min(1),
|
|
76
|
+
target: WindowEventTargetSchema,
|
|
77
|
+
payload: z.unknown(),
|
|
78
|
+
})
|
|
79
|
+
.strict();
|
|
80
|
+
export type WindowEventEnvelope = z.infer<typeof WindowEventEnvelopeSchema>;
|
|
81
|
+
|
|
82
|
+
/** Canonical window-lifecycle channels emitted by the Rust core. */
|
|
83
|
+
export const WINDOW_TOPICS = {
|
|
84
|
+
opened: 'window://opened',
|
|
85
|
+
closed: 'window://closed',
|
|
86
|
+
focusChanged: 'window://focus-changed',
|
|
87
|
+
} as const;
|
|
88
|
+
export type WindowTopic = (typeof WINDOW_TOPICS)[keyof typeof WINDOW_TOPICS];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Channels that MUST be window-targeted (`emit_to`) rather than broadcast,
|
|
92
|
+
* because a broadcast races across windows (first-reply-wins / wrong-window).
|
|
93
|
+
* Everything not listed here stays broadcast + per-window topic filtering.
|
|
94
|
+
* (plans/multi-window 03-research-internal: the ~25-channel routing table.)
|
|
95
|
+
*/
|
|
96
|
+
export const WINDOW_TARGETED_CHANNELS = [
|
|
97
|
+
'screenshot://request',
|
|
98
|
+
'screenshot://shortcut',
|
|
99
|
+
'projects:active-changed',
|
|
100
|
+
] as const;
|