@synap-core/workspace-templates 0.6.0 → 0.6.1
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/index.d.ts +247 -4
- package/dist/index.js +1762 -630
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/src/agent-fleet.yaml +16 -8
- package/src/catalog.test.ts +156 -0
- package/src/catalog.ts +285 -0
- package/src/communication.yaml +28 -1
- package/src/content-os.yaml +545 -0
- package/src/content-studio.yaml +0 -268
- package/src/crm.yaml +196 -0
- package/src/define.ts +268 -5
- package/src/enterprise-os.yaml +183 -0
- package/src/index.ts +27 -1
- package/src/marketing.yaml +14 -5
- package/src/templates.ts +1676 -656
- package/src/the-arch.yaml +182 -0
- package/src/types.ts +51 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synap-core/workspace-templates",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"packageManager": "pnpm@9.15.4",
|
|
6
6
|
"description": "Canonical workspace definitions — YAML source + typed exports for browser, backend, CLI, and marketplace",
|
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
"scripts": {
|
|
19
19
|
"type-check": "tsc --noEmit",
|
|
20
20
|
"build:yaml": "node build.mjs",
|
|
21
|
-
"
|
|
21
|
+
"check:converters": "node scripts/check-converters.mjs",
|
|
22
|
+
"build": "node build.mjs && tsup && node scripts/check-converters.mjs",
|
|
22
23
|
"provision": "node scripts/provision.mjs",
|
|
23
24
|
"eval": "node scripts/eval-standard.mjs",
|
|
24
|
-
"prepublishOnly": "node build.mjs && tsup"
|
|
25
|
+
"prepublishOnly": "node build.mjs && tsup && node scripts/check-converters.mjs"
|
|
25
26
|
},
|
|
26
27
|
"dependencies": {
|
|
27
28
|
"zod": "^3.0.0",
|
package/src/agent-fleet.yaml
CHANGED
|
@@ -895,28 +895,36 @@ displayTemplates:
|
|
|
895
895
|
bento:
|
|
896
896
|
viewName: "Agent Fleet Home"
|
|
897
897
|
blocks:
|
|
898
|
-
-
|
|
898
|
+
- kind: widget
|
|
899
|
+
widgetType: welcome-header
|
|
899
900
|
pos: { x: 0, y: 0, w: 12, h: 2 }
|
|
900
901
|
config: { title: "Agent Fleet", subtitle: "Your sovereign control plane for the agent fleet." }
|
|
901
|
-
-
|
|
902
|
+
- kind: widget
|
|
903
|
+
widgetType: entity-count
|
|
902
904
|
pos: { x: 0, y: 2, w: 3, h: 2 }
|
|
903
905
|
config: { profileSlug: agent, label: Agents, icon: bot }
|
|
904
|
-
-
|
|
906
|
+
- kind: widget
|
|
907
|
+
widgetType: entity-count
|
|
905
908
|
pos: { x: 3, y: 2, w: 3, h: 2 }
|
|
906
909
|
config: { profileSlug: task, label: Tasks, icon: check-square }
|
|
907
|
-
-
|
|
910
|
+
- kind: widget
|
|
911
|
+
widgetType: entity-count
|
|
908
912
|
pos: { x: 6, y: 2, w: 3, h: 2 }
|
|
909
913
|
config: { profileSlug: skill, label: Skills, icon: zap }
|
|
910
|
-
-
|
|
914
|
+
- kind: widget
|
|
915
|
+
widgetType: entity-count
|
|
911
916
|
pos: { x: 9, y: 2, w: 3, h: 2 }
|
|
912
917
|
config: { profileSlug: provider, label: Providers, icon: database }
|
|
913
|
-
-
|
|
918
|
+
- kind: widget
|
|
919
|
+
widgetType: entity-spotlight
|
|
914
920
|
pos: { x: 0, y: 4, w: 4, h: 4 }
|
|
915
921
|
config: { profileSlug: agent, seed: random, layout: detail, subtitleField: status, color: "#1A3A5C" }
|
|
916
|
-
-
|
|
922
|
+
- kind: widget
|
|
923
|
+
widgetType: entity-list
|
|
917
924
|
pos: { x: 4, y: 4, w: 4, h: 4 }
|
|
918
925
|
config: { profileSlug: task, title: "Active Tasks", limit: 6, variant: compact }
|
|
919
|
-
-
|
|
926
|
+
- kind: widget
|
|
927
|
+
widgetType: entity-list
|
|
920
928
|
pos: { x: 8, y: 4, w: 4, h: 4 }
|
|
921
929
|
config: { profileSlug: skill, title: "Top Skills", limit: 6, variant: compact }
|
|
922
930
|
- kind: view
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the mergeCatalog door. Run: `npx tsx --test src/catalog.test.ts`
|
|
3
|
+
*
|
|
4
|
+
* These test the SOURCE (`./catalog.js` → catalog.ts), never `dist/`, so no build
|
|
5
|
+
* is required and there is no stale-generated-output trap.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { mergeCatalog, type RemoteEntry } from "./catalog.js";
|
|
11
|
+
import type { WorkspaceYaml } from "./types.js";
|
|
12
|
+
|
|
13
|
+
const BUNDLE_VERSION = "0.6.0";
|
|
14
|
+
|
|
15
|
+
/** Minimal bundled template — only the fields the door reads. */
|
|
16
|
+
function bundled(slug: string, extra?: Partial<WorkspaceYaml["meta"]>): WorkspaceYaml {
|
|
17
|
+
return {
|
|
18
|
+
meta: { slug, name: slug, description: `${slug} desc`, icon: "layers", color: "#fff", ...extra },
|
|
19
|
+
workspace: { name: slug, description: "" },
|
|
20
|
+
profiles: [],
|
|
21
|
+
} as WorkspaceYaml;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Minimal CP row. `version` (when given) lands at definition.sourcePackage.version. */
|
|
25
|
+
function remote(slug: string, opts?: { version?: string; isPrivate?: boolean }): RemoteEntry {
|
|
26
|
+
return {
|
|
27
|
+
slug,
|
|
28
|
+
name: slug,
|
|
29
|
+
description: `${slug} remote`,
|
|
30
|
+
category: "workspace",
|
|
31
|
+
tags: [],
|
|
32
|
+
isPrivate: opts?.isPrivate,
|
|
33
|
+
definition:
|
|
34
|
+
opts?.version !== undefined
|
|
35
|
+
? { sourcePackage: { name: "@synap-core/workspace-templates", version: opts.version } }
|
|
36
|
+
: undefined,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Dedupe by slug ──────────────────────────────────────────────────────────
|
|
41
|
+
// MUTATION PROOF: change the dedupe key to `slug@version` and this count goes to
|
|
42
|
+
// 2 (bundled "1.0.0"-implied vs remote) → RED.
|
|
43
|
+
test("dedupes a slug present in both sources into ONE entry", () => {
|
|
44
|
+
const { entries } = mergeCatalog({
|
|
45
|
+
bundled: [bundled("crm"), bundled("legal")],
|
|
46
|
+
remote: [remote("crm", { version: "0.5.0" }), remote("legal", { version: "0.5.0" })],
|
|
47
|
+
bundleVersion: BUNDLE_VERSION,
|
|
48
|
+
});
|
|
49
|
+
assert.equal(entries.length, 2, "two slugs, deduped");
|
|
50
|
+
assert.equal(entries.filter((e) => e.slug === "crm").length, 1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ── Winner rule ─────────────────────────────────────────────────────────────
|
|
54
|
+
test("bundle wins when remote version EQUALS the bundle", () => {
|
|
55
|
+
const { entries } = mergeCatalog({
|
|
56
|
+
bundled: [bundled("crm")],
|
|
57
|
+
remote: [remote("crm", { version: BUNDLE_VERSION })],
|
|
58
|
+
bundleVersion: BUNDLE_VERSION,
|
|
59
|
+
});
|
|
60
|
+
assert.equal(entries[0].source, "bundled");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("bundle wins when remote version is OLDER", () => {
|
|
64
|
+
const { entries } = mergeCatalog({
|
|
65
|
+
bundled: [bundled("crm")],
|
|
66
|
+
remote: [remote("crm", { version: "0.5.0" })],
|
|
67
|
+
bundleVersion: BUNDLE_VERSION,
|
|
68
|
+
});
|
|
69
|
+
assert.equal(entries[0].source, "bundled");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// MUTATION PROOF: flip `remoteIsStrictlyNewer`'s comparison (`>` → `<`, or return
|
|
73
|
+
// false) and this assertion (source === "remote") goes RED.
|
|
74
|
+
test("remote wins when it is STRICTLY newer (this client is stale)", () => {
|
|
75
|
+
const { entries } = mergeCatalog({
|
|
76
|
+
bundled: [bundled("crm")],
|
|
77
|
+
remote: [remote("crm", { version: "0.7.0" })],
|
|
78
|
+
bundleVersion: BUNDLE_VERSION,
|
|
79
|
+
});
|
|
80
|
+
assert.equal(entries[0].source, "remote");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ── Third-party & squatting ─────────────────────────────────────────────────
|
|
84
|
+
test("a non-colliding remote slug is kept as a DISTINCT entry", () => {
|
|
85
|
+
const { entries } = mergeCatalog({
|
|
86
|
+
bundled: [bundled("crm")],
|
|
87
|
+
remote: [remote("acme-widget")], // no sourcePackage, no bundled twin
|
|
88
|
+
bundleVersion: BUNDLE_VERSION,
|
|
89
|
+
});
|
|
90
|
+
assert.equal(entries.length, 2);
|
|
91
|
+
const acme = entries.find((e) => e.slug === "acme-widget");
|
|
92
|
+
assert.equal(acme?.source, "remote");
|
|
93
|
+
assert.equal(acme?.isPrivate, false, "browse rows default public");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a third-party row SQUATTING an official slug (no sourcePackage) loses to the bundle", () => {
|
|
97
|
+
const { entries } = mergeCatalog({
|
|
98
|
+
bundled: [bundled("crm")],
|
|
99
|
+
remote: [remote("crm")], // slug collision, NO sourcePackage → squatting
|
|
100
|
+
bundleVersion: BUNDLE_VERSION,
|
|
101
|
+
});
|
|
102
|
+
assert.equal(entries.length, 1);
|
|
103
|
+
assert.equal(entries[0].source, "bundled");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── sources degradation (load-bearing) ──────────────────────────────────────
|
|
107
|
+
test("remote unreachable → returns bundled only + honest source status", () => {
|
|
108
|
+
const { entries, sources } = mergeCatalog({
|
|
109
|
+
bundled: [bundled("crm"), bundled("legal")],
|
|
110
|
+
remote: [], // fetch failed → empty
|
|
111
|
+
bundleVersion: BUNDLE_VERSION,
|
|
112
|
+
remoteStatus: "unreachable",
|
|
113
|
+
});
|
|
114
|
+
assert.equal(entries.length, 2, "still shows all bundled");
|
|
115
|
+
assert.equal(sources.bundled, "ok");
|
|
116
|
+
assert.equal(sources.remote, "unreachable");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("remote unauthenticated → status surfaced; any stray remote rows are ignored", () => {
|
|
120
|
+
const { entries, sources } = mergeCatalog({
|
|
121
|
+
bundled: [bundled("crm")],
|
|
122
|
+
remote: [remote("private-pack", { isPrivate: true })], // must NOT be merged
|
|
123
|
+
bundleVersion: BUNDLE_VERSION,
|
|
124
|
+
remoteStatus: "unauthenticated",
|
|
125
|
+
});
|
|
126
|
+
assert.equal(entries.length, 1);
|
|
127
|
+
assert.equal(entries[0].slug, "crm");
|
|
128
|
+
assert.equal(sources.remote, "unauthenticated");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("private remote packages merge when reachable and carry isPrivate", () => {
|
|
132
|
+
const { entries } = mergeCatalog({
|
|
133
|
+
bundled: [bundled("crm")],
|
|
134
|
+
remote: [remote("private-pack", { isPrivate: true })],
|
|
135
|
+
bundleVersion: BUNDLE_VERSION,
|
|
136
|
+
remoteStatus: "ok",
|
|
137
|
+
});
|
|
138
|
+
const priv = entries.find((e) => e.slug === "private-pack");
|
|
139
|
+
assert.equal(priv?.source, "remote");
|
|
140
|
+
assert.equal(priv?.isPrivate, true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ── Real bundled data shape (integration-ish) ───────────────────────────────
|
|
144
|
+
test("maps a bundled template's meta fields onto the entry", () => {
|
|
145
|
+
const { entries } = mergeCatalog({
|
|
146
|
+
bundled: [bundled("crm", { icon: "handshake", tags: ["crm", "sales"], isPublic: false })],
|
|
147
|
+
remote: [],
|
|
148
|
+
bundleVersion: BUNDLE_VERSION,
|
|
149
|
+
});
|
|
150
|
+
const e = entries[0];
|
|
151
|
+
assert.equal(e.source, "bundled");
|
|
152
|
+
assert.equal(e.icon, "handshake");
|
|
153
|
+
assert.deepEqual(e.tags, ["crm", "sales"]);
|
|
154
|
+
assert.equal(e.category, "workspace");
|
|
155
|
+
assert.equal(e.isPrivate, true, "isPublic:false ⇒ isPrivate");
|
|
156
|
+
});
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mergeCatalog — the ONE merge door for the workspace-template catalog.
|
|
3
|
+
* ====================================================================
|
|
4
|
+
*
|
|
5
|
+
* Both marketplace entry points (browser + CLI) and the pod need to show ONE
|
|
6
|
+
* list of installable workspace templates drawn from TWO sources:
|
|
7
|
+
*
|
|
8
|
+
* 1. BUNDLED — the canonical `@synap-core/workspace-templates` package that
|
|
9
|
+
* ships inside every client (this package). Always present, zero network.
|
|
10
|
+
* 2. REMOTE — rows in the control-plane package registry (`GET /api/packages`),
|
|
11
|
+
* which includes both the official templates re-published by
|
|
12
|
+
* `seed-templates.ts` AND private / third-party packages that never shipped
|
|
13
|
+
* in the bundle.
|
|
14
|
+
*
|
|
15
|
+
* The two overlap: every official template exists in BOTH sources (once bundled,
|
|
16
|
+
* once seeded to the CP). Naively concatenating them shows 21 duplicate cards.
|
|
17
|
+
* `mergeCatalog` de-dupes them into ONE catalog with a deterministic winner.
|
|
18
|
+
*
|
|
19
|
+
* WHY THIS LIVES HERE (and is PURE):
|
|
20
|
+
* This is the ONLY module the root `pnpm-workspace.yaml` lets `synap-cli`,
|
|
21
|
+
* `browser`, `synap-control-plane-api`, AND `synap-backend/packages/api` all
|
|
22
|
+
* resolve. `@synap-core/marketplace` pulls React and is `private: true`, so the
|
|
23
|
+
* Node CLI cannot import it. Keep this file pure data — no fetch, no auth, no
|
|
24
|
+
* React, no Node-only APIs. Each entry point has a DIFFERENT transport (browser
|
|
25
|
+
* cookie/`cpFetch`, CLI Bearer, pod anonymous); a door that fetched would force
|
|
26
|
+
* ONE auth model on all three and become the next lossy seam. Transport lives
|
|
27
|
+
* in the consumers; this door only merges arrays they already fetched.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { WorkspaceYaml } from "./types.js";
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// Inputs
|
|
34
|
+
// ============================================================================
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A bundled template — the canonical `WorkspaceYaml` this package ships. Callers
|
|
38
|
+
* pass `listPublicTemplates()` (18) or `listWorkspaceTemplates()` (21); the door
|
|
39
|
+
* merges whatever it is given and does NOT filter (see the publicity note below).
|
|
40
|
+
*/
|
|
41
|
+
export type BundledEntry = WorkspaceYaml;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A control-plane registry row, mapped into the minimal shape the door needs.
|
|
45
|
+
* Consumers build this from a `MarketplacePackage` (browser) / the CP
|
|
46
|
+
* `GET /api/packages` JSON (CLI) — a thin, transport-specific projection.
|
|
47
|
+
*
|
|
48
|
+
* `definition` is carried OPAQUE: it is both (a) the install payload the consumer
|
|
49
|
+
* feeds back to the pod, and (b) the home of the ONE comparable version signal,
|
|
50
|
+
* `definition.sourcePackage.version` — the real npm semver of the published
|
|
51
|
+
* `@synap-core/workspace-templates` build that generated an official row
|
|
52
|
+
* (`seed-templates.ts` stamps it). The door only reads that one nested field; the
|
|
53
|
+
* rest of `definition` is passed through untouched.
|
|
54
|
+
*/
|
|
55
|
+
export interface RemoteEntry {
|
|
56
|
+
slug: string;
|
|
57
|
+
name: string;
|
|
58
|
+
description?: string;
|
|
59
|
+
/** CP package category / type (e.g. `"workspace"`, `"view"`). */
|
|
60
|
+
category?: string;
|
|
61
|
+
tags?: string[];
|
|
62
|
+
icon?: string | null;
|
|
63
|
+
/**
|
|
64
|
+
* `true` when the CP row is not publicly listed (`isPublic === false`). Browse
|
|
65
|
+
* rows leave this `undefined` BY CONSTRUCTION — the browse route hard-filters
|
|
66
|
+
* `WHERE is_public = true`, so every browse row is public; `undefined` ⇒ public,
|
|
67
|
+
* never "unknown-so-hide".
|
|
68
|
+
*/
|
|
69
|
+
isPrivate?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* The CP `definition` jsonb — opaque install payload. The door reads only
|
|
72
|
+
* `definition.sourcePackage.version` (the winner signal). Absent on browse rows
|
|
73
|
+
* (the list route strips `definition`), which is fine: absence ⇒ bundle wins.
|
|
74
|
+
*/
|
|
75
|
+
definition?: {
|
|
76
|
+
sourcePackage?: { name?: string; version?: string };
|
|
77
|
+
[k: string]: unknown;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether the caller could reach / authenticate against the remote source. */
|
|
82
|
+
export type RemoteSourceStatus = "ok" | "unauthenticated" | "unreachable";
|
|
83
|
+
|
|
84
|
+
export interface MergeCatalogInput {
|
|
85
|
+
/** Bundled templates (this package). Always "ok" — it is in-process data. */
|
|
86
|
+
bundled: BundledEntry[];
|
|
87
|
+
/** Already-fetched CP rows. Empty/omitted when the fetch failed. */
|
|
88
|
+
remote?: RemoteEntry[];
|
|
89
|
+
/**
|
|
90
|
+
* This client's bundled-package version (`package.json` `version`, live `0.6.0`).
|
|
91
|
+
* Compared against each remote row's `sourcePackage.version` to decide whether
|
|
92
|
+
* THIS client's bundle is stale. Callers pass the compiled-in package version.
|
|
93
|
+
*/
|
|
94
|
+
bundleVersion: string;
|
|
95
|
+
/**
|
|
96
|
+
* The transport's verdict on the remote fetch. `"ok"` (default) ⇒ merge the
|
|
97
|
+
* `remote` array. `"unauthenticated"` / `"unreachable"` ⇒ ignore `remote` and
|
|
98
|
+
* surface the degradation in `sources.remote`, so the consumer can render
|
|
99
|
+
* "18 available — private ones need login" instead of FAILING OPEN EMPTY.
|
|
100
|
+
*/
|
|
101
|
+
remoteStatus?: RemoteSourceStatus;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ============================================================================
|
|
105
|
+
// Output
|
|
106
|
+
// ============================================================================
|
|
107
|
+
|
|
108
|
+
interface CatalogEntryBase {
|
|
109
|
+
slug: string;
|
|
110
|
+
name: string;
|
|
111
|
+
description?: string;
|
|
112
|
+
/** Package category / type. Bundled templates are always `"workspace"`. */
|
|
113
|
+
category?: string;
|
|
114
|
+
tags: string[];
|
|
115
|
+
icon?: string | null;
|
|
116
|
+
/** `true` ⇒ not publicly listed (internal bundled template, or private CP row). */
|
|
117
|
+
isPrivate: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A catalog entry backed by a bundled template. Install via `toWorkspaceDefinition()` / `toPackageDefinition()` on `template`. */
|
|
121
|
+
export interface BundledCatalogEntry extends CatalogEntryBase {
|
|
122
|
+
source: "bundled";
|
|
123
|
+
template: WorkspaceYaml;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A catalog entry backed by a CP registry row. Install from `remote.definition`. */
|
|
127
|
+
export interface RemoteCatalogEntry extends CatalogEntryBase {
|
|
128
|
+
source: "remote";
|
|
129
|
+
remote: RemoteEntry;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** One merged card. `source` discriminates the install path; common fields feed a picker. */
|
|
133
|
+
export type CatalogEntry = BundledCatalogEntry | RemoteCatalogEntry;
|
|
134
|
+
|
|
135
|
+
export interface MergeCatalogResult {
|
|
136
|
+
entries: CatalogEntry[];
|
|
137
|
+
/**
|
|
138
|
+
* Per-source health. LOAD-BEARING, not decoration: it is what lets a consumer
|
|
139
|
+
* distinguish "remote returned nothing" from "remote unreachable" and avoid the
|
|
140
|
+
* FAILS-OPEN-EMPTY trap (agent sees empty catalog → thinks pod is empty → dupes).
|
|
141
|
+
*/
|
|
142
|
+
sources: { bundled: "ok"; remote: RemoteSourceStatus };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ============================================================================
|
|
146
|
+
// Winner signal
|
|
147
|
+
// ============================================================================
|
|
148
|
+
|
|
149
|
+
function parseSemver(v: string | undefined): [number, number, number] | null {
|
|
150
|
+
if (!v) return null;
|
|
151
|
+
const m = /^\s*v?(\d+)\.(\d+)\.(\d+)/.exec(v);
|
|
152
|
+
if (!m) return null; // e.g. a range string ("range:>=0.6.0 <1.0.0") — not a version
|
|
153
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Is the remote row STRICTLY newer than this client's bundle? Only then does the
|
|
158
|
+
* remote win a slug conflict — it means THIS client is stale and the CP has a
|
|
159
|
+
* fresher official build. Every other case (equal, older, unparseable, or no
|
|
160
|
+
* `sourcePackage` at all) ⇒ `false` ⇒ BUNDLE WINS. That default is correct four
|
|
161
|
+
* ways, strongest last: (1) the CP is provably staler in the common case; (2) the
|
|
162
|
+
* CP row is a lossy re-encode of the bundle; (3) zero network; (4) the pod's boot
|
|
163
|
+
* reconcile already converges every workspace to the BUNDLED shape — bundle-wins
|
|
164
|
+
* agrees with the reconciler; CP-wins would fight it every boot.
|
|
165
|
+
*/
|
|
166
|
+
function remoteIsStrictlyNewer(
|
|
167
|
+
remoteVersion: string | undefined,
|
|
168
|
+
bundleVersion: string,
|
|
169
|
+
): boolean {
|
|
170
|
+
const r = parseSemver(remoteVersion);
|
|
171
|
+
const b = parseSemver(bundleVersion);
|
|
172
|
+
if (!r || !b) return false;
|
|
173
|
+
for (let i = 0; i < 3; i++) {
|
|
174
|
+
if (r[i] > b[i]) return true;
|
|
175
|
+
if (r[i] < b[i]) return false;
|
|
176
|
+
}
|
|
177
|
+
return false; // equal ⇒ not strictly newer ⇒ bundle wins
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ============================================================================
|
|
181
|
+
// Mapping
|
|
182
|
+
// ============================================================================
|
|
183
|
+
|
|
184
|
+
function bundledToEntry(tpl: WorkspaceYaml): BundledCatalogEntry {
|
|
185
|
+
return {
|
|
186
|
+
source: "bundled",
|
|
187
|
+
slug: tpl.meta.slug,
|
|
188
|
+
name: tpl.meta.name,
|
|
189
|
+
description: tpl.meta.description,
|
|
190
|
+
// Every bundled template is a full workspace — the CP calls this category
|
|
191
|
+
// "workspace" (PACKAGE_TYPES). Finer display grouping is derived from `tags`
|
|
192
|
+
// by the consumer (browser `categoryFor`), not baked in here.
|
|
193
|
+
category: "workspace",
|
|
194
|
+
tags: tpl.meta.tags ?? [],
|
|
195
|
+
icon: tpl.meta.icon,
|
|
196
|
+
isPrivate: tpl.meta.isPublic === false,
|
|
197
|
+
template: tpl,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function remoteToEntry(row: RemoteEntry): RemoteCatalogEntry {
|
|
202
|
+
return {
|
|
203
|
+
source: "remote",
|
|
204
|
+
slug: row.slug,
|
|
205
|
+
name: row.name,
|
|
206
|
+
description: row.description,
|
|
207
|
+
category: row.category,
|
|
208
|
+
tags: row.tags ?? [],
|
|
209
|
+
icon: row.icon ?? null,
|
|
210
|
+
isPrivate: row.isPrivate ?? false,
|
|
211
|
+
remote: row,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ============================================================================
|
|
216
|
+
// The door
|
|
217
|
+
// ============================================================================
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Merge bundled + remote template catalogs into ONE de-duplicated list.
|
|
221
|
+
*
|
|
222
|
+
* DEDUPE KEY = `slug` — NOT `slug@version`. This is forced by the live data, not
|
|
223
|
+
* a preference: every CP flagship row is hardcoded `version: "1.0.0"`
|
|
224
|
+
* (`seed-templates.ts`) and `TemplateMeta` carries NO version field at all, so a
|
|
225
|
+
* `slug@version` key would collide on nothing → 21 duplicate cards. The real
|
|
226
|
+
* per-build version lives only in `definition.sourcePackage.version`, which is
|
|
227
|
+
* the WINNER signal, never the identity key.
|
|
228
|
+
*
|
|
229
|
+
* CONFLICT RESOLUTION (remote slug already claimed by a bundled template):
|
|
230
|
+
* • remote `sourcePackage.version` strictly > `bundleVersion` → remote wins
|
|
231
|
+
* (this client is stale — the CP has a fresher official build).
|
|
232
|
+
* • otherwise (equal / older / no `sourcePackage`) → BUNDLE wins. The
|
|
233
|
+
* no-`sourcePackage` case is a third-party package SQUATTING an official slug;
|
|
234
|
+
* the bundle wins unconditionally (squatting defense).
|
|
235
|
+
*
|
|
236
|
+
* A remote slug that does NOT collide with any bundled template is a DISTINCT
|
|
237
|
+
* entry (a private or third-party package) and is always kept.
|
|
238
|
+
*/
|
|
239
|
+
export function mergeCatalog(input: MergeCatalogInput): MergeCatalogResult {
|
|
240
|
+
const { bundled, bundleVersion } = input;
|
|
241
|
+
const remoteStatus: RemoteSourceStatus = input.remoteStatus ?? "ok";
|
|
242
|
+
const remote = input.remote ?? [];
|
|
243
|
+
|
|
244
|
+
// Insertion-ordered map keyed by slug. Bundled entries seed it first, so they
|
|
245
|
+
// are the default winner; a qualifying remote row overwrites in place.
|
|
246
|
+
const bySlug = new Map<string, CatalogEntry>();
|
|
247
|
+
|
|
248
|
+
for (const tpl of bundled) {
|
|
249
|
+
bySlug.set(tpl.meta.slug, bundledToEntry(tpl));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Only fold in remote rows when the transport actually reached the source.
|
|
253
|
+
// A failed fetch must NOT silently look like "the CP has no templates".
|
|
254
|
+
if (remoteStatus === "ok") {
|
|
255
|
+
for (const row of remote) {
|
|
256
|
+
const existing = bySlug.get(row.slug);
|
|
257
|
+
|
|
258
|
+
if (!existing) {
|
|
259
|
+
// No bundled template owns this slug → distinct (private / third-party).
|
|
260
|
+
bySlug.set(row.slug, remoteToEntry(row));
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (existing.source !== "bundled") {
|
|
265
|
+
// Two remote rows with the same slug (shouldn't happen — CP slugs are
|
|
266
|
+
// unique) — keep the first, defensively.
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Slug conflict with an official bundled template. Remote wins ONLY if it
|
|
271
|
+
// is a strictly-newer official build; else the bundle stays (older, equal,
|
|
272
|
+
// or a squatting third-party with no sourcePackage).
|
|
273
|
+
const remoteVersion = row.definition?.sourcePackage?.version;
|
|
274
|
+
if (remoteIsStrictlyNewer(remoteVersion, bundleVersion)) {
|
|
275
|
+
bySlug.set(row.slug, remoteToEntry(row));
|
|
276
|
+
}
|
|
277
|
+
// else: no-op — bundled entry already in place wins.
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
entries: [...bySlug.values()],
|
|
283
|
+
sources: { bundled: "ok", remote: remoteStatus },
|
|
284
|
+
};
|
|
285
|
+
}
|
package/src/communication.yaml
CHANGED
|
@@ -28,8 +28,35 @@ workspace:
|
|
|
28
28
|
- brand.templates
|
|
29
29
|
- brand.components
|
|
30
30
|
- narrative.positioning
|
|
31
|
+
# Communication CONSUMES brand and PROVIDES narrative.
|
|
32
|
+
#
|
|
33
|
+
# `brand: provider` here was a LIVE bug, not dead metadata. brand-library.yaml
|
|
34
|
+
# also declares `brand: provider`, and the IS reads this to pick which
|
|
35
|
+
# workspace supplies brand for EVERY agent's system prompt
|
|
36
|
+
# (`resolveWorkspaceForCapability` in agents/utils/context-manager.ts): it
|
|
37
|
+
# filters candidates by the `brand.library` capability, then takes the FIRST
|
|
38
|
+
# workspace whose sourceRoles.brand is a provider. With both installed, "first"
|
|
39
|
+
# is whatever order listWorkspaces returned — an arbitrary, non-deterministic
|
|
40
|
+
# brand voice across turns. brand-library is the canonical brand source (this
|
|
41
|
+
# file's brand profiles are, by its own admission below, verbatim from it);
|
|
42
|
+
# Communication is the narrative/voice layer that reads from it.
|
|
43
|
+
#
|
|
44
|
+
# Every other domain in this template set has exactly ONE provider and names
|
|
45
|
+
# its consumers (content-studio: `brand: consumer`; marketing: `brand: consumer`).
|
|
46
|
+
# `brand` was the only domain with two. This restores that invariant.
|
|
47
|
+
#
|
|
48
|
+
# Expressed as a source ROLE, not `defaultSources.brand`, because a
|
|
49
|
+
# TemplateDefaultSource requires a concrete `workspaceId` (types.ts) that does
|
|
50
|
+
# not exist until provisioning — a template cannot know it. finance.yaml makes
|
|
51
|
+
# the same call for `strategy` and says so. `defaultSources` remains the
|
|
52
|
+
# correct RUNTIME override (`synap_declare_workspace_source`).
|
|
53
|
+
#
|
|
54
|
+
# The `brand.library` capability above is deliberately KEPT: it is what the
|
|
55
|
+
# resolver filters candidates on, and it makes this workspace a graceful
|
|
56
|
+
# fallback (step 3) when brand-library is not installed — in which case the
|
|
57
|
+
# local brand profiles below are exactly what the agent should read.
|
|
31
58
|
sourceRoles:
|
|
32
|
-
brand:
|
|
59
|
+
brand: consumer
|
|
33
60
|
narrative: provider
|
|
34
61
|
|
|
35
62
|
profiles:
|