@roubo/shared 0.1.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.
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Declarative source-picker payloads returned by an integration plugin's
3
+ * `listSourceCandidates` RPC. Roubo's host renders the UI; plugins ship no
4
+ * React. See `.specifications/integration-plugins/architecture.md` (FR-019).
5
+ */
6
+
7
+ export type SourceCandidateIcon = "repo" | "project" | "board" | "epic" | "filter";
8
+
9
+ export interface SourceCandidateItem {
10
+ externalId: string;
11
+ label: string;
12
+ sublabel?: string;
13
+ icon?: SourceCandidateIcon;
14
+ }
15
+
16
+ export interface SourceCandidateCategory {
17
+ id: string;
18
+ label: string;
19
+ items: SourceCandidateItem[];
20
+ }
21
+
22
+ export type SourceCandidatesShape =
23
+ | "multi-list"
24
+ | "categorized-multi-list"
25
+ | "searchable-categorized";
26
+
27
+ // One selectable mode within a synthetic category (e.g. "assigned to me":
28
+ // in-project vs anywhere). Distinct from a SourceCandidateItem in that it has
29
+ // no externalId and is not fetched via search; the host renders it inline.
30
+ export interface SourceCategoryOption {
31
+ id: string;
32
+ label: string;
33
+ }
34
+
35
+ // A category declared by the "searchable-categorized" shape. The plugin ships
36
+ // no items here; it only declares which categories exist, their icon, and
37
+ // whether each is gated behind a parent selection. Items arrive later via the
38
+ // host's source-options search RPC.
39
+ export interface SearchableSourceCategory {
40
+ id: "project" | "board" | "filter" | "epic" | "mine";
41
+ label: string;
42
+ icon?: SourceCandidateIcon;
43
+ // Gate: the category is disabled until the named parent selection exists.
44
+ scopedBy?: "project";
45
+ // Inline modes for synthetic categories like "mine".
46
+ options?: SourceCategoryOption[];
47
+ }
48
+
49
+ export interface SourceCandidatesResponse {
50
+ shape: SourceCandidatesShape;
51
+ // Present iff shape === "multi-list".
52
+ items?: SourceCandidateItem[];
53
+ // Present iff shape === "categorized-multi-list".
54
+ categories?: SourceCandidateCategory[];
55
+ // Present iff shape === "searchable-categorized".
56
+ searchableCategories?: SearchableSourceCategory[];
57
+ // Reserved for future pagination; v1 plugins return undefined.
58
+ nextCursor?: string | null;
59
+ }
60
+
61
+ /**
62
+ * Params for the scoped, paginated source-option search (`getSourceOptions`).
63
+ * Generalizes facet-option search with a parent `scope` (the Jira project keys a
64
+ * board/filter/epic search is confined to) and an opaque `cursor`. `search` is
65
+ * the optional user-typed term (debounced client-side). Scoped categories with
66
+ * no `scope.project` return an empty page.
67
+ */
68
+ export interface GetSourceOptionsParams {
69
+ category: "project" | "board" | "filter" | "epic";
70
+ scope?: { project?: string[] };
71
+ search?: string;
72
+ cursor?: string | null;
73
+ }
74
+
75
+ /**
76
+ * One page of source options returned by `getSourceOptions`. `nextCursor` is an
77
+ * opaque token the host passes back verbatim to fetch the next page; `null`
78
+ * means exhausted (NFR-004: every item reachable, no page dropped or duplicated).
79
+ */
80
+ export interface SourceOptionsResult {
81
+ items: SourceCandidateItem[];
82
+ nextCursor: string | null;
83
+ }
84
+
85
+ /**
86
+ * One persisted source entry. The primitive `string` form is the externalId;
87
+ * the object form carries per-source toggles (currently only the GitHub-family
88
+ * Code Scanning / Secret Scanning / Dependabot booleans, ignored by other
89
+ * plugins). The two forms are interchangeable on disk: a writer collapses to
90
+ * the primitive form when no toggles are set, and expands to the object form
91
+ * the moment any toggle turns on.
92
+ */
93
+ export type SourceSelectionEntry =
94
+ | string
95
+ | {
96
+ externalId: string;
97
+ // Human-readable display name and secondary line for the source, captured
98
+ // when the user picks it so chips and the Settings tile can show the name
99
+ // instead of the raw id on reload (no re-fetch). Display only: the plugin
100
+ // and `translateSources` ignore them.
101
+ label?: string;
102
+ sublabel?: string;
103
+ // Jira project key the source is scoped to (project-first model). Set on
104
+ // board / filter / epic entries and on the synthetic `mine` source when
105
+ // scoped in-project, so removing a project can prune its scoped sources.
106
+ project?: string;
107
+ // Board sources: active sprint only vs the whole board's backing filter.
108
+ boardMode?: "active-sprint" | "whole-board";
109
+ // "Assigned to me" synthetic source: scoped to a project or instance-wide.
110
+ mineScope?: "in-project" | "anywhere";
111
+ // GitHub-family toggles (ignored by other plugins).
112
+ includeCodeQLAlerts?: boolean;
113
+ includeSecretScanningAlerts?: boolean;
114
+ includeDependabotAlerts?: boolean;
115
+ };
116
+
117
+ /**
118
+ * Persisted selection for a project. Keys are plugin-defined category ids
119
+ * (or the literal `"items"` for the multi-list shape). Values are arrays of
120
+ * source entries (string externalIds or objects with per-source toggles).
121
+ * Stored verbatim in `integration.sources` of the per-user override and
122
+ * treated as opaque-to-Roubo elsewhere.
123
+ */
124
+ export type SourceSelection = Record<string, SourceSelectionEntry[]>;
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@roubo/shared",
3
+ "version": "0.1.0",
4
+ "description": "Shared TypeScript types for Roubo client + server.",
5
+ "author": "David Poxon",
6
+ "license": "Apache-2.0",
7
+ "homepage": "https://github.com/davidpoxon/roubo#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/davidpoxon/roubo.git",
11
+ "directory": "shared"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/davidpoxon/roubo/issues"
15
+ },
16
+ "type": "module",
17
+ "main": "./types.ts",
18
+ "types": "./types.ts",
19
+ "exports": {
20
+ ".": "./types.ts",
21
+ "./provision-descriptor-schema": "./provision-descriptor-schema.ts",
22
+ "./testbench-contracts": "./testbench-contracts.ts",
23
+ "./testbench-canonicalize": "./testbench-canonicalize.ts",
24
+ "./testbench-domain": "./testbench-domain.ts",
25
+ "./work-units-contract": "./work-units-contract.ts",
26
+ "./gate-overrides-contract": "./gate-overrides-contract.ts"
27
+ },
28
+ "files": [
29
+ "*.ts",
30
+ "!*.test.ts"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "dependencies": {
36
+ "yaml": "2.9.0",
37
+ "zod": "4.4.3"
38
+ }
39
+ }
@@ -0,0 +1,80 @@
1
+ import { z } from "zod";
2
+ import type { PluginPermissions } from "./plugin-manifest-schema.js";
3
+
4
+ // Issue #615 / CP-FR-011, CP-FR-012, CP-NFR-001. Per-plugin consent record.
5
+ // See:
6
+ // .specifications/component-plugins/prd.md (CP-FR-011, CP-FR-012, CP-NFR-001)
7
+ // .specifications/component-plugins/architecture.md ('Data model', lines 61, 108-109)
8
+ //
9
+ // v1 ships the declare-then-enforce trust model's declaration half: the consumer
10
+ // is shown every declared permission category and must acknowledge them before a
11
+ // component plugin runs. A ConsentRecord captures that acknowledgement so the
12
+ // server can refuse to start a component whose plugin has none. v2 adds runtime
13
+ // enforcement and sandboxing; this record is advisory in v1.
14
+
15
+ export const PLUGIN_CONSENT_STATE_SCHEMA_VERSION = 1 as const;
16
+
17
+ // The advisory permission categories a component plugin can declare. Kept in one
18
+ // place so the consent store, the route, and the UI agree on the canonical set.
19
+ export const PERMISSION_CATEGORIES = [
20
+ "network",
21
+ "credentials",
22
+ "filesystem",
23
+ "processes",
24
+ "ports",
25
+ "docker",
26
+ ] as const;
27
+ export type PermissionCategory = (typeof PERMISSION_CATEGORIES)[number];
28
+
29
+ export const ConsentRecordSchema = z
30
+ .object({
31
+ pluginId: z.string().min(1),
32
+ acknowledgedCategories: z.array(z.string().min(1)),
33
+ consentedAt: z.string().min(1),
34
+ })
35
+ .strict();
36
+ export type ConsentRecord = z.infer<typeof ConsentRecordSchema>;
37
+
38
+ export const PluginConsentStateSchema = z
39
+ .object({
40
+ schemaVersion: z.literal(PLUGIN_CONSENT_STATE_SCHEMA_VERSION),
41
+ plugins: z.record(z.string().min(1), ConsentRecordSchema),
42
+ })
43
+ .strict();
44
+ export type PluginConsentState = z.infer<typeof PluginConsentStateSchema>;
45
+
46
+ /**
47
+ * Enumerates the permission categories a manifest actually declares, i.e. the
48
+ * categories the consumer must acknowledge before the plugin may run. A category
49
+ * is "declared" only when it requests something: an empty `network.hosts`,
50
+ * `credentials.slots`, or `filesystem.paths`, a `false` `processes` / `ports` /
51
+ * `docker`, or an absent optional category all count as not-declared and so do
52
+ * not require acknowledgement.
53
+ *
54
+ * Order follows PERMISSION_CATEGORIES so the UI and the route render a stable
55
+ * sequence.
56
+ */
57
+ export function declaredCategories(permissions: PluginPermissions): PermissionCategory[] {
58
+ const declared: PermissionCategory[] = [];
59
+ if (permissions.network.hosts.length > 0) declared.push("network");
60
+ if (permissions.credentials.slots.length > 0) declared.push("credentials");
61
+ if (permissions.filesystem.paths.length > 0) declared.push("filesystem");
62
+ if (permissions.processes !== false) declared.push("processes");
63
+ if (permissions.ports !== undefined && permissions.ports !== false) declared.push("ports");
64
+ if (permissions.docker !== undefined && permissions.docker !== false) declared.push("docker");
65
+ return declared;
66
+ }
67
+
68
+ /**
69
+ * True when every category the manifest declares appears in `acknowledged`.
70
+ * Extra acknowledged categories are tolerated (forward-compatible); the gate is
71
+ * only that no declared category is missing. Used by the POST /consent route to
72
+ * reject a body that omits any declared category (400).
73
+ */
74
+ export function isFullyAcknowledged(
75
+ permissions: PluginPermissions,
76
+ acknowledged: readonly string[],
77
+ ): boolean {
78
+ const ack = new Set(acknowledged);
79
+ return declaredCategories(permissions).every((cat) => ack.has(cat));
80
+ }
@@ -0,0 +1,30 @@
1
+ import { z } from "zod";
2
+
3
+ // WU-046 / issue #137: persistent per-plugin enable state. See:
4
+ // .specifications/integration-plugins/prd.md (FR-059, FR-060, NFR-019, US-016)
5
+ // .specifications/integration-plugins/architecture.md (lines 1064-1097)
6
+
7
+ export const PLUGIN_ENABLE_STATE_SCHEMA_VERSION = 1 as const;
8
+
9
+ /**
10
+ * Manifest ids of the bundled plugins shipped in `<repo>/plugins/`. The
11
+ * greenfield migration path seeds `plugins-state.json` with one `"disabled"`
12
+ * entry per id so a fresh install opts in to each integration explicitly.
13
+ */
14
+ export const BUNDLED_PLUGIN_IDS = ["github-com", "ghe", "jira-self-hosted"] as const;
15
+ export type BundledPluginId = (typeof BUNDLED_PLUGIN_IDS)[number];
16
+
17
+ export const PluginEnableStateValueSchema = z.enum(["enabled", "disabled"]);
18
+ export type PluginEnableStateValue = z.infer<typeof PluginEnableStateValueSchema>;
19
+
20
+ export const PluginEnableStateSchema = z
21
+ .object({
22
+ schemaVersion: z.literal(PLUGIN_ENABLE_STATE_SCHEMA_VERSION),
23
+ plugins: z.record(z.string().min(1), PluginEnableStateValueSchema),
24
+ // Sentinel that this install has been through the greenfield seeding pass.
25
+ // Prevents re-seeding a fresh-cloned alpha install that happens to look
26
+ // greenfield. Set to true at the same atomic write that seeds `plugins`.
27
+ installInitialized: z.boolean(),
28
+ })
29
+ .strict();
30
+ export type PluginEnableState = z.infer<typeof PluginEnableStateSchema>;
@@ -0,0 +1,179 @@
1
+ import { z } from "zod";
2
+
3
+ // ── Sub-schemas ──
4
+
5
+ export const CredentialSlotSchema = z
6
+ .object({
7
+ slot: z.string().min(1, "Required"),
8
+ scope: z.enum(["read", "read-write"]),
9
+ description: z.string().min(1, "Required"),
10
+ })
11
+ .strict();
12
+ export type CredentialSlot = z.infer<typeof CredentialSlotSchema>;
13
+
14
+ export const NetworkPermissionsSchema = z
15
+ .object({
16
+ hosts: z.array(z.string()),
17
+ })
18
+ .strict();
19
+ export type NetworkPermissions = z.infer<typeof NetworkPermissionsSchema>;
20
+
21
+ export const CredentialsPermissionsSchema = z
22
+ .object({
23
+ slots: z.array(CredentialSlotSchema),
24
+ })
25
+ .strict();
26
+ export type CredentialsPermissions = z.infer<typeof CredentialsPermissionsSchema>;
27
+
28
+ export const FilesystemPermissionsSchema = z
29
+ .object({
30
+ paths: z.array(z.string()),
31
+ })
32
+ .strict();
33
+ export type FilesystemPermissions = z.infer<typeof FilesystemPermissionsSchema>;
34
+
35
+ export const ProcessesPermissionSchema = z.union([
36
+ z.literal(false),
37
+ z.object({ executables: z.array(z.string()) }).strict(),
38
+ ]);
39
+ export type ProcessesPermission = z.infer<typeof ProcessesPermissionSchema>;
40
+
41
+ // `ports` lets a component plugin declare the bench ports it needs allocated.
42
+ // Either false (no port allocation) or an object naming the port keys the host
43
+ // resolves into BenchContext.ports (architecture.md, FR-001/FR-011).
44
+ export const PortsPermissionSchema = z.union([
45
+ z.literal(false),
46
+ z.object({ names: z.array(z.string()) }).strict(),
47
+ ]);
48
+ export type PortsPermission = z.infer<typeof PortsPermissionSchema>;
49
+
50
+ // `docker` gates a component plugin's access to the host docker broker
51
+ // (composeUp / waitForHealthy / assignContainer, etc.). Either false (no docker
52
+ // access) or an object (reserved for future scoping fields).
53
+ export const DockerPermissionSchema = z.union([z.literal(false), z.object({}).strict()]);
54
+ export type DockerPermission = z.infer<typeof DockerPermissionSchema>;
55
+
56
+ // `permissions` is intentionally `.passthrough()` (not `.strict()`) so future
57
+ // permission categories can be added in a 1.x minor without breaking older
58
+ // hosts. See decisions-log.md AF-002. `ports` and `docker` are the component
59
+ // categories (optional, so existing integration manifests validate unchanged).
60
+ export const PluginPermissionsSchema = z
61
+ .object({
62
+ network: NetworkPermissionsSchema,
63
+ credentials: CredentialsPermissionsSchema,
64
+ filesystem: FilesystemPermissionsSchema,
65
+ processes: ProcessesPermissionSchema,
66
+ ports: PortsPermissionSchema.optional(),
67
+ docker: DockerPermissionSchema.optional(),
68
+ })
69
+ .passthrough();
70
+ export type PluginPermissions = z.infer<typeof PluginPermissionsSchema>;
71
+
72
+ // Per-capability flags a tracker plugin declares for the privileged tracker-action
73
+ // ops the TrackerActionGateway gates (verify-gate FR-011, NFR-005; spike #704).
74
+ // A flag is true only when the plugin implements the op for the connected
75
+ // instance. The gateway reads these up front and degrades with a legible error
76
+ // (never a silent no-op) when a flag is absent or false. close-gate is not a new
77
+ // flag: it reuses the existing applyTransition capability (architecture.md:133-134).
78
+ export const PluginCapabilitiesSchema = z
79
+ .object({
80
+ /** The plugin implements `createIssue` for the connected instance (FR-011). */
81
+ supportsCreateIssue: z.boolean().optional(),
82
+ /**
83
+ * The plugin implements `addBlockedBy` AND the connected instance exposes the
84
+ * blocking-link write (the GitHub GA / GHE version / Jira link-type condition
85
+ * from spike #704). May be resolved at runtime, not just statically.
86
+ */
87
+ supportsBlockingLinks: z.boolean().optional(),
88
+ })
89
+ .strict();
90
+ export type PluginCapabilities = z.infer<typeof PluginCapabilitiesSchema>;
91
+
92
+ // Plugin-global defaults seeded into the three-layer effective-config merge
93
+ // (FR-064). Per-project and per-source layers override these. The host reads
94
+ // this section at manifest parse time; plugins do not see it via host-RPC.
95
+ export const PluginDefaultIntegrationConfigSchema = z
96
+ .object({
97
+ excludedStatuses: z.array(z.string().min(1)).optional(),
98
+ // Plugin-global default for the category-first status exclusion (FR-010).
99
+ // Seeded by the plugin manifest (e.g. jira-self-hosted ships ["Done"]).
100
+ excludedStatusCategories: z.array(z.string().min(1)).optional(),
101
+ })
102
+ .strict();
103
+ export type PluginDefaultIntegrationConfig = z.infer<typeof PluginDefaultIntegrationConfigSchema>;
104
+
105
+ // Per FR-057 / mockups §22, plugins may ship an icon rendered in the
106
+ // Plugins-page tile header (32×32) and the Configure modal header (24×24).
107
+ // Accepted forms:
108
+ // - `data:image/svg+xml;...` or `data:image/png;base64,...` data URI
109
+ // - relative POSIX path inside the plugin directory (e.g. `assets/icon.svg`)
110
+ // Loose validation here: the client renders this as an <img src>; manifest
111
+ // authors are trusted to ship something sensible. 16 KB ceiling guards
112
+ // against accidentally shipping a megabyte of base64.
113
+ export const PluginIconSchema = z
114
+ .string()
115
+ .min(1, "Required")
116
+ .max(16 * 1024, "Icon must be at most 16 KB");
117
+ export type PluginIcon = z.infer<typeof PluginIconSchema>;
118
+
119
+ // The set of plugin kinds the host understands. `component` lands with the
120
+ // component-plugin work (FR-001); `integration` is the original kind. The
121
+ // discriminator widens here without breaking existing integration manifests.
122
+ export const PluginKindSchema = z.enum(["integration", "component"]);
123
+ export type PluginKind = z.infer<typeof PluginKindSchema>;
124
+
125
+ // A node-semver-compatible range, used to validate the manifest `roubo` field
126
+ // at schema time so a malformed range is rejected with a clear error (FR-001),
127
+ // rather than only at host-compatibility time. Kept dependency-free (the
128
+ // `shared` workspace depends only on `yaml` + `zod`): this validates each
129
+ // space- or `||`-separated comparator against the comparator grammar
130
+ // node-semver accepts (operators, hyphen ranges, x-ranges, caret/tilde,
131
+ // wildcards). It is intentionally permissive on the comparator side and strict
132
+ // only about rejecting obvious garbage (the host re-checks with node-semver).
133
+ const SEMVER_COMPARATOR =
134
+ /^(?:[<>]=?|=|\^|~)?\s*v?(?:\d+|[xX*])(?:\.(?:\d+|[xX*]))?(?:\.(?:\d+|[xX*]))?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
135
+
136
+ export function isValidRouboRange(range: string): boolean {
137
+ const trimmed = range.trim();
138
+ if (trimmed.length === 0) return false;
139
+ // `*` / `x` on their own is the match-anything range.
140
+ if (/^[*xX]$/.test(trimmed)) return true;
141
+ const orClauses = trimmed.split("||");
142
+ return orClauses.every((clause) => {
143
+ const comparators = clause.trim().split(/\s+/).filter(Boolean);
144
+ if (comparators.length === 0) return false;
145
+ // A hyphen range ("1.2.3 - 2.3.4") parses as [lo, "-", hi].
146
+ if (comparators.length === 3 && comparators[1] === "-") {
147
+ return SEMVER_COMPARATOR.test(comparators[0]) && SEMVER_COMPARATOR.test(comparators[2]);
148
+ }
149
+ return comparators.every((c) => SEMVER_COMPARATOR.test(c));
150
+ });
151
+ }
152
+
153
+ // ── Root manifest ──
154
+
155
+ export const PluginManifestSchema = z
156
+ .object({
157
+ id: z
158
+ .string()
159
+ .regex(/^[a-z][a-z0-9-]*$/, "Must be kebab-case (lowercase letters, digits, hyphens)"),
160
+ name: z.string().min(1, "Required"),
161
+ version: z.string().min(1, "Required"),
162
+ description: z.string().min(1, "Required"),
163
+ kind: PluginKindSchema,
164
+ roubo: z.string().min(1, "Required").refine(isValidRouboRange, "Must be a valid semver range"),
165
+ entry: z.string().min(1, "Required"),
166
+ icon: PluginIconSchema.optional(),
167
+ configSchema: z.record(z.string(), z.unknown()).optional(),
168
+ capabilities: PluginCapabilitiesSchema.optional(),
169
+ defaultIntegrationConfig: PluginDefaultIntegrationConfigSchema.optional(),
170
+ permissions: PluginPermissionsSchema,
171
+ // Component plugins declare the SDK component-contract version they target
172
+ // (FR-001/FR-002); the host validates the registered-method set against it.
173
+ contractVersion: z.number().int().positive().optional(),
174
+ // Optional ProvisionDescriptor schema version a declarative component plugin
175
+ // emits, so the host can reject a descriptor-schema mismatch (FR-017).
176
+ descriptorSchemaVersion: z.number().int().positive().optional(),
177
+ })
178
+ .strict();
179
+ export type PluginManifest = z.infer<typeof PluginManifestSchema>;
@@ -0,0 +1,55 @@
1
+ import { parse as parseYaml, YAMLParseError } from "yaml";
2
+ import { PluginManifestSchema, type PluginManifest } from "./plugin-manifest-schema.js";
3
+
4
+ export type ParseManifestResult =
5
+ | { ok: true; manifest: PluginManifest }
6
+ | {
7
+ ok: false;
8
+ error: {
9
+ code: "invalid-yaml" | "schema";
10
+ message: string;
11
+ path?: string;
12
+ };
13
+ };
14
+
15
+ export function parseManifest(yamlText: string, sourcePath: string): ParseManifestResult {
16
+ let raw: unknown;
17
+ try {
18
+ raw = parseYaml(yamlText);
19
+ } catch (err) {
20
+ const message = err instanceof YAMLParseError ? err.message : (err as Error).message;
21
+ return {
22
+ ok: false,
23
+ error: {
24
+ code: "invalid-yaml",
25
+ message: `Failed to parse ${sourcePath}: ${message}`,
26
+ },
27
+ };
28
+ }
29
+
30
+ if (raw === null || raw === undefined) {
31
+ return {
32
+ ok: false,
33
+ error: {
34
+ code: "invalid-yaml",
35
+ message: `${sourcePath} is empty`,
36
+ },
37
+ };
38
+ }
39
+
40
+ const parsed = PluginManifestSchema.safeParse(raw);
41
+ if (!parsed.success) {
42
+ const issue = parsed.error.issues[0];
43
+ const path = issue.path.length > 0 ? issue.path.join(".") : undefined;
44
+ return {
45
+ ok: false,
46
+ error: {
47
+ code: "schema",
48
+ message: path ? `${path}: ${issue.message}` : issue.message,
49
+ path,
50
+ },
51
+ };
52
+ }
53
+
54
+ return { ok: true, manifest: parsed.data };
55
+ }
@@ -0,0 +1,50 @@
1
+ import type { PluginManifest } from "./plugin-manifest-schema.js";
2
+
3
+ export type PluginStatus = "enabled" | "disabled" | "errored" | "incompatible" | "invalid";
4
+
5
+ export type PluginSource = "bundled" | "user";
6
+
7
+ export interface RestartEvent {
8
+ at: string;
9
+ reason: "unexpected-exit" | "spawn-failed" | "sandbox-fallback";
10
+ exitCode: number | null;
11
+ }
12
+
13
+ export interface PluginError {
14
+ code: string;
15
+ message: string;
16
+ methodName?: string;
17
+ }
18
+
19
+ export interface LogLine {
20
+ ts: string;
21
+ source: "stdout" | "stderr" | "host";
22
+ level?: "info" | "warn" | "error";
23
+ text: string;
24
+ }
25
+
26
+ /**
27
+ * A structured, user-visible notice that the docker isolation tier could not
28
+ * engage for the plugin's directory. Surfaced on PluginRecord so callers of
29
+ * listInstalled() can present an actionable remediation rather than relying
30
+ * only on the log line (#743).
31
+ */
32
+ export interface IsolationNotice {
33
+ kind: "docker-mount-unshared";
34
+ pluginDir: string;
35
+ message: string;
36
+ at: string;
37
+ }
38
+
39
+ export interface PluginRecord {
40
+ id: string;
41
+ manifest: PluginManifest | null;
42
+ manifestPath: string;
43
+ pluginDir: string;
44
+ source: PluginSource;
45
+ status: PluginStatus;
46
+ lastError: PluginError | null;
47
+ restartHistory: RestartEvent[];
48
+ pid: number | null;
49
+ isolationNotices?: IsolationNotice[];
50
+ }
@@ -0,0 +1,103 @@
1
+ import { z } from "zod";
2
+
3
+ // Issue #603 / T1.2: the typed ProvisionDescriptor discriminated union that a
4
+ // component plugin emits and the host LifecycleEngine executes. See:
5
+ // .specifications/component-plugins/prd.md (FR-002, FR-022, US-005, US-012)
6
+ // .specifications/component-plugins/architecture.md ('Data model', line 54)
7
+ //
8
+ // The shape is frozen in architecture.md. It lives in shared/ so both the host
9
+ // LifecycleEngine and the plugin SDK import one contract without a circular
10
+ // dependency. Every variant carries a top-level schemaVersion so the host can
11
+ // validate a descriptor and reject a mismatched version (the z.literal gate
12
+ // below fails validation when the version does not match).
13
+
14
+ export const SUPPORTED_PROVISION_SCHEMA_VERSION = 1 as const;
15
+
16
+ // ── docker ──
17
+ // A compose-backed component: the host brings up `service` in `composeFile`,
18
+ // optionally running `initService` first, optionally running a `migration`
19
+ // command once the service is healthy, and optionally exposing a connection
20
+ // string via `connection.template`.
21
+
22
+ const DockerMigrationSchema = z
23
+ .object({
24
+ command: z.string().min(1),
25
+ args: z.array(z.string()).optional(),
26
+ })
27
+ .strict();
28
+
29
+ const DockerConnectionSchema = z
30
+ .object({
31
+ template: z.string().min(1),
32
+ })
33
+ .strict();
34
+
35
+ export const DockerProvisionDescriptorSchema = z
36
+ .object({
37
+ schemaVersion: z.literal(SUPPORTED_PROVISION_SCHEMA_VERSION),
38
+ kind: z.literal("docker"),
39
+ composeFile: z.string().min(1),
40
+ service: z.string().min(1),
41
+ initService: z.string().min(1).optional(),
42
+ portEnvVar: z.string().min(1).optional(),
43
+ migration: DockerMigrationSchema.optional(),
44
+ connection: DockerConnectionSchema.optional(),
45
+ assignedContainerId: z.string().min(1).optional(),
46
+ // Component-level env injected into the compose interpolation environment
47
+ // (and the migration process env), merged alongside the allocated port. This
48
+ // mirrors the built-in database path, which folds `componentConfig.env` into
49
+ // the compose `portOverrides` (see bench-manager `startDockerComponent`), so a
50
+ // plugin-backed database reaches env parity (CP-FR-004, CP-FR-007).
51
+ env: z.record(z.string(), z.string()).optional(),
52
+ healthcheck: z.boolean().optional(),
53
+ })
54
+ .strict();
55
+ export type DockerProvisionDescriptor = z.infer<typeof DockerProvisionDescriptorSchema>;
56
+
57
+ // ── process ──
58
+ // A long-running process the host owns: `command` is started in `cwd` with the
59
+ // merged `env` / `envFile`, optionally after running a one-time `setup`, once
60
+ // the named `dependsOn` components are up.
61
+
62
+ export const ProcessProvisionDescriptorSchema = z
63
+ .object({
64
+ schemaVersion: z.literal(SUPPORTED_PROVISION_SCHEMA_VERSION),
65
+ kind: z.literal("process"),
66
+ command: z.string().min(1),
67
+ env: z.record(z.string(), z.string()).optional(),
68
+ envFile: z.string().min(1).optional(),
69
+ cwd: z.string().min(1).optional(),
70
+ setup: z.string().min(1).optional(),
71
+ dependsOn: z.array(z.string()).optional(),
72
+ })
73
+ .strict();
74
+ export type ProcessProvisionDescriptor = z.infer<typeof ProcessProvisionDescriptorSchema>;
75
+
76
+ // ── oneshot ──
77
+ // A run-to-completion command (the FR-022 deploy stress-test shape): like a
78
+ // process but expected to exit, with an optional `timeoutMs` ceiling.
79
+
80
+ export const OneshotProvisionDescriptorSchema = z
81
+ .object({
82
+ schemaVersion: z.literal(SUPPORTED_PROVISION_SCHEMA_VERSION),
83
+ kind: z.literal("oneshot"),
84
+ command: z.string().min(1),
85
+ env: z.record(z.string(), z.string()).optional(),
86
+ envFile: z.string().min(1).optional(),
87
+ cwd: z.string().min(1).optional(),
88
+ dependsOn: z.array(z.string()).optional(),
89
+ timeoutMs: z.number().int().positive().optional(),
90
+ })
91
+ .strict();
92
+ export type OneshotProvisionDescriptor = z.infer<typeof OneshotProvisionDescriptorSchema>;
93
+
94
+ // ── union ──
95
+ // Discriminated on `kind`; schemaVersion is a literal field on each member, so
96
+ // a mismatched version fails validation without any z.intersection wrapping.
97
+
98
+ export const ProvisionDescriptorSchema = z.discriminatedUnion("kind", [
99
+ DockerProvisionDescriptorSchema,
100
+ ProcessProvisionDescriptorSchema,
101
+ OneshotProvisionDescriptorSchema,
102
+ ]);
103
+ export type ProvisionDescriptor = z.infer<typeof ProvisionDescriptorSchema>;