mirror of
https://github.com/mvschwarz/openrig-studio.git
synced 2026-11-07 05:56:21 -10:00
Initial release — OpenRig Studio SDK, contract v0.1
An app shell and a contract, not a framework you link against. A rail of surfaces, a stage, an agent sidebar, and a protocol for how a surface talks to them. An app conforms to the contract; it never imports this package. - app/ the reference runtime and shell - contract/ the contract: shell protocol, manifest, row schema, runtime API - create-studio/ a scaffolder for a new studio - skills/ how to build an app on this SDK, written from experience - fixtures/ example rig state for the FLOOR surface - test/ 73 tests, including the consumer-surface seam and redeploy survival Consumers register surfaces from OUTSIDE the package via --surfaces, so nothing is written into node_modules and a copied install cannot carry another instance's rows. Licensed Apache-2.0.
This commit is contained in:
commit
2c1e40e6b1
33 files changed
+4868
No files matched your search
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,4 @@
|
||||
OpenRig Studio
|
||||
Copyright 2026 mvschwarz
|
||||
|
||||
This product includes software developed by mvschwarz.
|
||||
@@ -0,0 +1,58 @@
|
||||
# openrig studio
|
||||
|
||||
An SDK for building agent-driven mini-apps: a thin browser surface paired
|
||||
with an agent, running in a shared shell. Point your coding agent at this
|
||||
repository and it can build you a surface.
|
||||
|
||||
## Quickstart
|
||||
|
||||
Requires Node 18+. No dependencies, no build step.
|
||||
|
||||
```sh
|
||||
node app/serve-studio.mjs
|
||||
```
|
||||
|
||||
Open http://127.0.0.1:8890/ — the shell. It starts with one surface on the rail, FLOOR, rendering the fixture rig state. Flags: `--port <n>`, `--fixtures <dir>`.
|
||||
|
||||
## Where everything is
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `contract/` | **The contract — start here.** Four documents + the manifest row schema. |
|
||||
| `create-studio/` | The scaffolder: generates a working surface project. |
|
||||
| `app/serve-studio.mjs` | The reference runtime (zero-dependency Node HTTP server). |
|
||||
| `app/shell.html` | The shell: rail + stage + agent sidebar. |
|
||||
| `app/surfaces.json` | The surface manifest the shell renders. |
|
||||
| `app/surfaces/` | Surface pages live here, served at `/surfaces/…`. |
|
||||
| `fixtures/` | The data behind the runtime's API verbs (rig floor state, files root). |
|
||||
|
||||
## Starting a new surface
|
||||
|
||||
The scaffolder emits a working surface project — page, schema-valid manifest
|
||||
row, and instructions — already wired to contract v0.1:
|
||||
|
||||
```sh
|
||||
node create-studio/index.mjs my-surface
|
||||
```
|
||||
|
||||
The intended public form is `npm create @openrig/studio my-surface`, which npm
|
||||
resolves to `@openrig/create-studio`. **That command is not available yet** —
|
||||
the package is unpublished and its final name is a deferred decision. Until
|
||||
then use the invocation above. See `create-studio/README.md`.
|
||||
|
||||
## Building a surface
|
||||
|
||||
Read `contract/contract-meta.md` first — it explains how to read the rest of
|
||||
the contract. Then: `contract/manifest.md` shows how a surface registers,
|
||||
`contract/shell-protocol.md` what the shell provides, and
|
||||
`contract/runtime-api.md` the HTTP verbs and their exact shapes. A running
|
||||
runtime self-describes at `GET /api/contract`.
|
||||
|
||||
The reference runtime is fixture-backed: its API verbs serve data from
|
||||
`fixtures/`, so everything works on any machine with no external processes.
|
||||
Editing a fixture file fires the change-signal stream — a convenient way to
|
||||
watch a live-updating surface work.
|
||||
|
||||
## Status
|
||||
|
||||
Contract v0.1. Pre-release.
|
||||
@@ -0,0 +1,738 @@
|
||||
#!/usr/bin/env node
|
||||
// openrig studio — reference runtime, contract v0.1.
|
||||
//
|
||||
// A zero-dependency HTTP server that is the SDK's contract boundary:
|
||||
// - serves the shell (shell.html) and the surfaces it hosts
|
||||
// - reads + validates the surface manifest (surfaces.json), WARN-FIRST
|
||||
// - GET /api/contract — version, capabilities, manifest validation report
|
||||
// - GET /api/factory/state — the observe envelope (fixture-backed)
|
||||
// - GET /api/events — SSE change-signal stream (signal-only contract)
|
||||
// - GET /api/files/* — read-only, root-pinned file verbs
|
||||
//
|
||||
// Fixture-backed by design: this runtime runs anywhere with no external
|
||||
// process. Data comes from --fixtures <dir> (default ./fixtures beside the
|
||||
// repo root). Live backings arrive behind the same contract in later
|
||||
// versions; the API shapes here are the contract, the fixtures are the demo.
|
||||
//
|
||||
// Usage: node app/serve-studio.mjs [--port 8890] [--fixtures <dir>]
|
||||
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(HERE, "..");
|
||||
const arg = (name, dflt) => {
|
||||
const i = process.argv.indexOf(name);
|
||||
return i > -1 && process.argv[i + 1] ? process.argv[i + 1] : dflt;
|
||||
};
|
||||
const PORT = Number(arg("--port", 8890));
|
||||
const FIXTURES = path.resolve(arg("--fixtures", path.join(ROOT, "fixtures")));
|
||||
const FILES_ROOT = path.join(FIXTURES, "files-root");
|
||||
const MANIFEST_PATH = path.join(HERE, "surfaces.json");
|
||||
|
||||
// ---- the consumer-surface seam ---------------------------------------------
|
||||
// A consumer of this SDK (a studio built ON it) needs to register ITS OWN
|
||||
// surfaces. Before this seam the only registration path pointed INSIDE the
|
||||
// package — which in a real install is node_modules/@openrig/studio/app/ —
|
||||
// gitignored, wiped by npm ci, and, worse, CARRIED BY A COPY: duplicating a
|
||||
// node_modules tree duplicated phantom surface state, so a contaminated
|
||||
// instance served 200 with a healthy manifest and the WRONG surfaces.
|
||||
//
|
||||
// The overlay puts BOTH halves outside the package: the consumer's own
|
||||
// surfaces.json AND its pages. A configurable manifest path alone would not
|
||||
// have done it — the rows would move out while the .html stayed in, so the
|
||||
// contamination class would survive in the half that actually carries state.
|
||||
// Because consumer surfaces never live in node_modules, copying node_modules
|
||||
// cannot inject one, and there is nothing to reconcile.
|
||||
//
|
||||
// Opt-in and additive. Unset, everything below behaves exactly as before and
|
||||
// the SDK's own FLOOR needs zero configuration.
|
||||
const OVERLAY_DIR = (() => {
|
||||
const raw = arg("--surfaces", process.env.OPENRIG_STUDIO_SURFACES);
|
||||
return raw ? path.resolve(raw) : null;
|
||||
})();
|
||||
const OVERLAY_MANIFEST = OVERLAY_DIR ? path.join(OVERLAY_DIR, "surfaces.json") : null;
|
||||
|
||||
const CONTRACT_VERSION = "0.1";
|
||||
const CAPABILITIES = ["contract.meta", "observe.factory-state", "stream.events", "files.read", "shell.protocol"];
|
||||
|
||||
const TYPES = { ".html": "text/html; charset=utf-8", ".json": "application/json", ".css": "text/css",
|
||||
".js": "text/javascript", ".mjs": "text/javascript", ".svg": "image/svg+xml", ".png": "image/png",
|
||||
".md": "text/plain; charset=utf-8", ".txt": "text/plain; charset=utf-8" };
|
||||
|
||||
const sendJson = (res, code, obj) =>
|
||||
{ res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
|
||||
|
||||
// ---- manifest: read + WARN-FIRST validation --------------------------------
|
||||
// Contract rule: a malformed manifest must LOOK wrong — named errors in the
|
||||
// startup log and in GET /api/contract's manifest block — but the runtime
|
||||
// keeps serving the valid rows. It never dies and never silently ignores.
|
||||
const ROW_OPTIONAL = { glyph: "string", hint: "string", category: "string", samehost: "boolean",
|
||||
port: "number", sub: "string", url: "string", hosts: "array", popout: "boolean",
|
||||
hideAgents: "boolean", unmountOnBlur: "boolean", railMin: "boolean", project: "string" };
|
||||
|
||||
// `label` is "" for a single-manifest runtime, so a zero-config install emits
|
||||
// byte-identical error strings to before the seam existed — labelling only
|
||||
// appears once there are two sources to tell apart, and only for consumers who
|
||||
// opted in. Changing an error contract is listed as BREAKING in contract-meta;
|
||||
// this keeps the unconfigured path genuinely untouched rather than nearly so.
|
||||
// `keep` filters rows BEFORE validation. Order matters: contamination must be
|
||||
// removed before the duplicate-id check, or a forged row PREPENDED to the
|
||||
// manifest gets the genuine row rejected as its duplicate and then gets dropped
|
||||
// itself as undeclared — suppressing the authoritative surface entirely. The
|
||||
// guarantee is that the served set IS the declared set, so a declared surface
|
||||
// has to survive contamination, not merely fail to be replaced by it.
|
||||
function validateManifest(raw, label = "", keep = null) {
|
||||
const tag = label ? `${label} ` : "";
|
||||
const errors = [], warnings = [], valid = [];
|
||||
let doc;
|
||||
try { doc = JSON.parse(raw); }
|
||||
catch (e) { return { errors: [`${tag}surfaces.json is not valid JSON: ${e.message}`], warnings, valid, doc: null }; }
|
||||
if (!Array.isArray(doc.surfaces)) {
|
||||
return { errors: [`${tag}surfaces.json must carry a top-level surfaces[] array`], warnings, valid, doc };
|
||||
}
|
||||
let rows = doc.surfaces;
|
||||
if (keep) {
|
||||
rows = [];
|
||||
doc.surfaces.forEach((row, i) => {
|
||||
if (row && typeof row === "object" && !keep(row)) {
|
||||
warnings.push(
|
||||
`${tag}surfaces[${i}]${row.id ? ` (id: ${row.id})` : ""}: NOT declared by this SDK — ` +
|
||||
`IGNORED. An undeclared row in the installed package is contamination (a copied tree, a ` +
|
||||
`stale materialisation, or a dependency writing into node_modules); surfaces you own ` +
|
||||
`belong in your own directory, registered via --surfaces`
|
||||
);
|
||||
return;
|
||||
}
|
||||
rows.push(row);
|
||||
});
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
rows.forEach((row, i) => {
|
||||
const where = `${tag}surfaces[${i}]${row && row.id ? ` (id: ${row.id})` : ""}`;
|
||||
const rowErrors = [];
|
||||
if (!row || typeof row !== "object") { errors.push(`${where}: row must be an object`); return; }
|
||||
if (typeof row.id !== "string" || !row.id) rowErrors.push(`${where}: id is required (non-empty string)`);
|
||||
else if (seen.has(row.id)) rowErrors.push(`${where}: duplicate id — ids must be unique`);
|
||||
else seen.add(row.id);
|
||||
if (typeof row.name !== "string" || !row.name) rowErrors.push(`${where}: name is required (non-empty string)`);
|
||||
// exactly ONE of path | url, and url must be absolute
|
||||
const hasPath = "path" in row, hasUrl = "url" in row;
|
||||
if (hasPath && hasUrl) rowErrors.push(`${where}: carries BOTH path and url — a row targets exactly one`);
|
||||
else if (!hasPath && !hasUrl) rowErrors.push(`${where}: needs exactly one of path (starting with "/") or an absolute url`);
|
||||
if (hasPath && !(typeof row.path === "string" && row.path.startsWith("/")))
|
||||
rowErrors.push(`${where}: path must be a string starting with "/"`);
|
||||
if (hasUrl) {
|
||||
// Two checks, both required (the same authority rule the schema's pattern
|
||||
// declares, then a real parse):
|
||||
// 1. syntactic: https?:// followed immediately by a non-empty authority —
|
||||
// the WHATWG parser NORMALIZES forms like "http:///path" (empty
|
||||
// authority) into "http://path/", silently diverging from the written
|
||||
// string the shell would actually load, so a parse alone cannot catch
|
||||
// them;
|
||||
// 2. parse: protocol + non-empty hostname (catches "https://" etc.).
|
||||
let parsed = null;
|
||||
try { parsed = typeof row.url === "string" ? new URL(row.url) : null; } catch {}
|
||||
const syntacticAuthority = typeof row.url === "string" && /^https?:\/\/[^/?#\s]+/.test(row.url);
|
||||
if (!syntacticAuthority || !parsed || !["http:", "https:"].includes(parsed.protocol) || !parsed.hostname)
|
||||
rowErrors.push(`${where}: url must be an absolute http(s) URL with a non-empty host (e.g. https://host/path)`);
|
||||
}
|
||||
for (const [k, v] of Object.entries(row)) {
|
||||
if (["id", "name", "path", "url"].includes(k)) continue; // handled above
|
||||
const want = ROW_OPTIONAL[k];
|
||||
if (want === undefined) { warnings.push(`${where}: unknown field "${k}" (ignored; fine for forward-compat)`); continue; }
|
||||
const got = Array.isArray(v) ? "array" : typeof v;
|
||||
if (got !== want) rowErrors.push(`${where}: ${k} must be ${want}, got ${got}`);
|
||||
}
|
||||
if (rowErrors.length) errors.push(...rowErrors);
|
||||
else valid.push(row);
|
||||
});
|
||||
return { errors, warnings, valid, doc };
|
||||
}
|
||||
|
||||
// ---- manifest sources: the package's own, plus the optional consumer overlay
|
||||
// Both go through the SAME load/recovery/guard code. Two copies of this logic
|
||||
// would drift, and the consumer manifest is the one a real deployment actually
|
||||
// redeploys — so it needs the redeploy-survival guarantees at least as much as the
|
||||
// package's does, not a simplified version of them.
|
||||
const signatureOf = (file) => {
|
||||
try {
|
||||
const st = fs.statSync(file);
|
||||
return `${st.ino}:${st.mtimeMs}:${st.size}`;
|
||||
} catch {
|
||||
return "missing";
|
||||
}
|
||||
};
|
||||
|
||||
function makeSource(manifestPath, label) {
|
||||
return {
|
||||
path: manifestPath,
|
||||
label,
|
||||
loadedSignature: "unloaded",
|
||||
result: { errors: [`${label ? label + " " : ""}surfaces.json not read yet`], warnings: [], valid: [], doc: null },
|
||||
state: {
|
||||
// "init" until the first load resolves. A process that starts healthy
|
||||
// must report recoveries: 0 — booting is not recovering, and a counter
|
||||
// that counted it would make "never broke" indistinguishable from
|
||||
// "recovered", which is the confusion these fields exist to remove.
|
||||
state: "init", // "init" | "ok" | "invalid" | "unreadable"
|
||||
lastLoadedAt: null,
|
||||
reloads: 0,
|
||||
recoveries: 0,
|
||||
lastRecoveryAt: null,
|
||||
// Reloads the WATCH did not cause — the file at the served path changed
|
||||
// identity and no event fired. Non-zero means the fast path is not
|
||||
// holding for this deployment shape and correctness rests on the stat
|
||||
// floor. (A directory swap is the usual reason.)
|
||||
integrityReloads: 0,
|
||||
lastIntegrityReloadAt: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- what the SDK declares as ITS OWN surfaces ------------------------------
|
||||
// AUTHORITY ROOT: in runtime source, because
|
||||
// app/surfaces.json is the artifact contamination mutates. Materialisation
|
||||
// appended rows to it and copied pages beside it; it never edited this file,
|
||||
// and a copied node_modules tree carries this source unmodified.
|
||||
//
|
||||
// The declaration is the WHOLE ROW, not the id. An id-only authority is
|
||||
// bypassable by reusing a declared id with a different path or label: an
|
||||
// injected { id: "floor", name: "HIJACKED FLOOR", path: "/surfaces/hijack.html" }
|
||||
// would be accepted as the SDK's own and serve attacker content under a trusted
|
||||
// name. Declaring the row means the SDK vouches for exactly what it ships.
|
||||
//
|
||||
// A hardcoded declaration is discipline, so a committed test asserts this set
|
||||
// deep-equals the rows the package actually ships — change FLOOR's path or name
|
||||
// without updating here and the suite fails.
|
||||
//
|
||||
// NOT covered, deliberately: a dependency shipping a MODIFIED runtime source.
|
||||
// That is supply-chain compromise, a higher-bar class already accepted by
|
||||
// installing the SDK at all. See contract/manifest.md.
|
||||
const SDK_DECLARED_SURFACES = [
|
||||
{ id: "floor", name: "FLOOR", glyph: "●", path: "/surfaces/floor.html",
|
||||
hint: "Live rig-floor seats and queue activity" },
|
||||
];
|
||||
|
||||
// Canonical form so field ORDER cannot smuggle a difference past equality.
|
||||
const canonical = (row) => JSON.stringify(Object.keys(row).sort().map((k) => [k, row[k]]));
|
||||
const DECLARED_CANONICAL = new Set(SDK_DECLARED_SURFACES.map(canonical));
|
||||
const isSdkDeclared = (row) => DECLARED_CANONICAL.has(canonical(row));
|
||||
|
||||
const packageSource = makeSource(MANIFEST_PATH, OVERLAY_MANIFEST ? "package" : "");
|
||||
const consumerSource = OVERLAY_MANIFEST ? makeSource(OVERLAY_MANIFEST, "consumer") : null;
|
||||
const sources = () => (consumerSource ? [packageSource, consumerSource] : [packageSource]);
|
||||
|
||||
function loadSource(src) {
|
||||
const previous = src.state.state;
|
||||
src.state.reloads += 1;
|
||||
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(src.path, "utf8");
|
||||
} catch (e) {
|
||||
src.result = { errors: [`cannot read ${src.label ? src.label + " " : ""}surfaces.json: ${e.message}`], warnings: [], valid: [], doc: null };
|
||||
src.state.state = "unreadable";
|
||||
console.error(`manifest ERROR: ${src.result.errors[0]}`);
|
||||
// Gone. If it returns at the same path it will be a NEW inode, so re-arm
|
||||
// rather than trusting a watch that may be bound to the old one.
|
||||
src.loadedSignature = signatureOf(src.path);
|
||||
rearmWatches();
|
||||
return;
|
||||
}
|
||||
|
||||
src.loadedSignature = signatureOf(src.path);
|
||||
// Authority is applied HERE, before validation, for the package source only
|
||||
// when a consumer overlay makes enforcement active.
|
||||
const keep = src === packageSource && consumerSource ? isSdkDeclared : null;
|
||||
src.result = validateManifest(raw, src.label, keep);
|
||||
src.state.state = src.result.errors.length === 0 ? "ok" : "invalid";
|
||||
if (src.state.state === "ok") src.state.lastLoadedAt = new Date().toISOString();
|
||||
|
||||
for (const e of src.result.errors) console.error(`manifest ERROR: ${e}`);
|
||||
for (const w of src.result.warnings) console.error(`manifest warn: ${w}`);
|
||||
|
||||
// A recovery is a TRANSITION into health, not merely being healthy.
|
||||
if (src.state.state === "ok" && previous !== "ok" && previous !== "init") {
|
||||
src.state.recoveries += 1;
|
||||
src.state.lastRecoveryAt = src.state.lastLoadedAt;
|
||||
console.log(
|
||||
`manifest RECOVERED: ${src.path} — ${src.result.valid.length} ${src.label || "package"} surface(s) ` +
|
||||
`after "${previous}" (recovery #${src.state.recoveries}, no restart)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function loadManifest() {
|
||||
for (const src of sources()) loadSource(src);
|
||||
}
|
||||
|
||||
// ---- the merged rail --------------------------------------------------------
|
||||
// Package rows first, then the consumer's. A consumer MAY deliberately replace
|
||||
// a stock surface by reusing its id — but never silently: shadowing is named in
|
||||
// warnings, because an operator who sees their own surface appear would
|
||||
// otherwise have no way to learn that something was displaced.
|
||||
function mergedSurfaces() {
|
||||
const warnings = [];
|
||||
const errors = [];
|
||||
const owner = new Map(); // row.path -> "package" | "consumer"
|
||||
|
||||
// ONE RULE: serve exactly what is authoritatively DECLARED — the SDK's own
|
||||
// surfaces plus the consumer's overlay-declared ones — and nothing else.
|
||||
// Trust what is declared, not what is present.
|
||||
//
|
||||
// With an overlay active, a row in the package manifest that the SDK does not
|
||||
// declare as its own is contamination: appended by materialisation, carried
|
||||
// by a copied tree, or injected by a dependency. It is IGNORED for serving
|
||||
// and WARNED, so a dirty package is visible rather than silently masked —
|
||||
// dropping it quietly would hide the contamination instead of surfacing it.
|
||||
// Undeclared package rows were already removed at validation time, before the
|
||||
// duplicate-id check, so everything surviving here is authoritative.
|
||||
const enforcing = Boolean(consumerSource);
|
||||
const packageRows = packageSource.result.valid;
|
||||
|
||||
const byId = new Map(packageRows.map((row) => [row.id, row]));
|
||||
for (const row of packageRows) {
|
||||
if (typeof row.path === "string") owner.set(row.path, "package");
|
||||
}
|
||||
if (consumerSource) {
|
||||
// A declared package row OWNS its path. A consumer row may take that path
|
||||
// ONLY by shadowing at the ID level — the documented, warned way to replace
|
||||
// a stock surface. Letting a different-id row claim it would hijack the
|
||||
// package's page silently: both rail entries would load consumer bytes with
|
||||
// no shadow warning, and replacement would become a filename act rather
|
||||
// than an id act, which is exactly what the stable rule forbids.
|
||||
const packagePaths = new Map(packageRows.map((r) => [r.path, r.id]).filter(([p]) => typeof p === "string"));
|
||||
for (const row of consumerSource.result.valid) {
|
||||
const collidesWith = typeof row.path === "string" ? packagePaths.get(row.path) : undefined;
|
||||
if (collidesWith !== undefined && collidesWith !== row.id) {
|
||||
errors.push(
|
||||
`consumer surfaces.json (id: ${row.id}): path "${row.path}" is already owned by the ` +
|
||||
`package surface "${collidesWith}" — EXCLUDED. Replace a package surface by reusing its ` +
|
||||
`id (which warns), not by reusing its path`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (byId.has(row.id)) {
|
||||
warnings.push(
|
||||
`consumer surfaces.json: id "${row.id}" SHADOWS the package surface of the same id — ` +
|
||||
`the consumer row is served`
|
||||
);
|
||||
}
|
||||
byId.set(row.id, row); // Map keeps the original position on overwrite
|
||||
if (typeof row.path === "string") owner.set(row.path, "consumer");
|
||||
}
|
||||
}
|
||||
|
||||
// A path only belongs to a source if a row that SURVIVED merge still claims it.
|
||||
const live = new Set([...byId.values()].map((r) => r.path).filter(Boolean));
|
||||
for (const key of [...owner.keys()]) if (!live.has(key)) owner.delete(key);
|
||||
|
||||
// Undeclared FILES sitting in the package are warned too — a phantom page can
|
||||
// be copied in without a row, and silence would hide it.
|
||||
if (enforcing) {
|
||||
const served = new Set([...owner.entries()].filter(([, src]) => src === "package").map(([p]) => path.basename(p)));
|
||||
let present = [];
|
||||
try { present = fs.readdirSync(path.join(HERE, "surfaces")).filter((f) => !f.startsWith(".")); } catch { /* none */ }
|
||||
for (const file of present) {
|
||||
if (!served.has(file)) {
|
||||
warnings.push(
|
||||
`package app/surfaces/${file}: present but NOT declared by this SDK — not served. ` +
|
||||
`An undeclared file in the installed package is contamination`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { rows: [...byId.values()], warnings, errors, owner };
|
||||
}
|
||||
|
||||
// The aggregate the rail actually is: every source's errors and warnings, and
|
||||
// the merged row count. Per-source detail hangs off it so a reader can tell
|
||||
// WHICH manifest is unhealthy rather than only that something is.
|
||||
function manifestReport() {
|
||||
const merged = mergedSurfaces();
|
||||
// Merge-level problems (a consumer row colliding with a package-owned path)
|
||||
// are errors in their own right, not just per-source validation failures.
|
||||
const errors = [...sources().flatMap((s) => s.result.errors), ...merged.errors];
|
||||
const warnings = [...sources().flatMap((s) => s.result.warnings), ...merged.warnings];
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
surfaces: merged.rows.length,
|
||||
// Top-level state fields describe the PACKAGE source, unchanged in meaning
|
||||
// from before the seam existed. The consumer's are under `consumer`.
|
||||
...packageSource.state,
|
||||
consumer: consumerSource
|
||||
? { dir: OVERLAY_DIR, surfaces: consumerSource.result.valid.length, ...consumerSource.state }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// NOTE: the startup load is deliberately NOT called here. loadManifest's
|
||||
// read-failure path reaches for the watcher machinery declared further down, so
|
||||
// calling it at this point put a `let` binding in its temporal dead zone and
|
||||
// the process died at boot whenever surfaces.json happened to be missing —
|
||||
// precisely the moment a redeploy is mid-flight. Hoisting the one binding that
|
||||
// tripped first only moved the tripwire. See the init block at the bottom of
|
||||
// this file: nothing runs until every declaration exists.
|
||||
|
||||
// ---- observe: the factory/state envelope (fixture-backed) ------------------
|
||||
// The envelope is runtime-designed and declared field-by-field in
|
||||
// contract/runtime-api.md. generatedAt is stamped fresh on every read so a
|
||||
// consumer can tell a live response from a cached one.
|
||||
function factoryState() {
|
||||
const f = path.join(FIXTURES, "factory-state.json");
|
||||
let d;
|
||||
try { d = JSON.parse(fs.readFileSync(f, "utf8")); }
|
||||
catch (e) { return { ok: false, degraded: `fixture unreadable: ${e.message}`, rig: null, seats: [], queue: [] }; }
|
||||
return { ok: true, rig: d.rig || "fixture", generatedAt: new Date().toISOString(),
|
||||
seats: Array.isArray(d.seats) ? d.seats : [], queue: Array.isArray(d.queue) ? d.queue : [] };
|
||||
}
|
||||
|
||||
// ---- stream: SSE change-signal (signal-only contract) ----------------------
|
||||
// Contract: a message means "state changed — re-fetch what you care about via
|
||||
// the observe verbs." Payload contents are unspecified in v0 and must not be
|
||||
// parsed. A `degraded` event means the signal source is unhealthy; fall back
|
||||
// to polling. Heartbeat comments keep intermediaries from closing the stream.
|
||||
const sseClients = new Set();
|
||||
function sseBroadcast() {
|
||||
for (const res of sseClients) { try { res.write(`data: change\n\n`); } catch {} }
|
||||
}
|
||||
// ---- watching: must survive delete -> recreate -----------------------------
|
||||
// Watching surfaces.json BY PATH binds the watch to that file's inode. Delete
|
||||
// and recreate it — which is what npm ci, write-tmp+rename deploys, redeploys
|
||||
// over an existing install, and save-by-rename editors all do — and the new
|
||||
// file has a NEW inode. The old watch never fires again, so the runtime sits on
|
||||
// ENOENT forever, serving an empty rail with a 200, until someone restarts it.
|
||||
//
|
||||
// Fix: watch the manifest's PARENT DIRECTORY and match by filename. A directory
|
||||
// inode survives files being replaced inside it, which is what makes this the
|
||||
// atomic-rename-safe pattern — and it covers in-place edit, delete+recreate and
|
||||
// tmp+rename with one mechanism.
|
||||
//
|
||||
// Belt and braces: rearmWatches() is also called when a load hits ENOENT and
|
||||
// when a watcher emits an error, so a watch lost some other way — including the
|
||||
// watched DIRECTORY itself being replaced, which the parent-dir watch does not
|
||||
// cover — re-establishes instead of dying silently.
|
||||
const MANIFEST_DIR = path.dirname(MANIFEST_PATH);
|
||||
const MANIFEST_FILE = path.basename(MANIFEST_PATH);
|
||||
|
||||
let watchers = [];
|
||||
let rearmTimer = null;
|
||||
|
||||
function watchForChanges() {
|
||||
for (const w of watchers) { try { w.close(); } catch {} }
|
||||
watchers = [];
|
||||
|
||||
const targets = [
|
||||
// The manifest's PARENT directory — not the manifest file itself.
|
||||
{ dir: MANIFEST_DIR, recursive: false, onEvent: (name) => {
|
||||
if (name === MANIFEST_FILE) loadSource(packageSource);
|
||||
} },
|
||||
{ dir: FIXTURES, recursive: true, onEvent: () => {} },
|
||||
{ dir: path.join(HERE, "surfaces"), recursive: true, onEvent: (name) => {
|
||||
if (name === MANIFEST_FILE) loadSource(packageSource);
|
||||
} },
|
||||
];
|
||||
|
||||
// The consumer overlay gets the SAME treatment. Without this, the redeploy-survival defect class returns and the
|
||||
// entire defect class returns for consumer surfaces — which are precisely the
|
||||
// ones a real deployment redeploys.
|
||||
if (consumerSource) {
|
||||
targets.push({ dir: OVERLAY_DIR, recursive: true, onEvent: (name) => {
|
||||
if (name === MANIFEST_FILE) loadSource(consumerSource);
|
||||
} });
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
try {
|
||||
const w = fs.watch(target.dir, { recursive: target.recursive }, (_ev, name) => {
|
||||
target.onEvent(name);
|
||||
sseBroadcast();
|
||||
});
|
||||
// A dead watcher must not fail silently — that is the whole defect class.
|
||||
w.on("error", () => rearmWatches());
|
||||
watchers.push(w);
|
||||
} catch { /* a missing watch target is fine; rearm will retry */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced so a burst of filesystem events (npm ci, a deploy) re-arms once
|
||||
// rather than once per event.
|
||||
function rearmWatches() {
|
||||
if (rearmTimer) return;
|
||||
rearmTimer = setTimeout(() => {
|
||||
rearmTimer = null;
|
||||
watchForChanges();
|
||||
}, 150);
|
||||
rearmTimer.unref?.();
|
||||
}
|
||||
|
||||
// ---- the integrity guard: watches are an optimisation, this is correctness --
|
||||
//
|
||||
// Every fs.watch in this file follows an INODE. That is fine until a redeploy
|
||||
// replaces the thing being watched, and there are two shapes that do:
|
||||
//
|
||||
// npm ci / delete+recreate — the FILE is replaced. The parent-dir watch
|
||||
// catches it, and the runtime goes ENOENT in
|
||||
// between, so the failure is at least visible.
|
||||
// mv new current — the DIRECTORY is replaced. The watch keeps
|
||||
// following the OLD directory inode, which still
|
||||
// exists under its new name. No event ever fires,
|
||||
// no error is raised, and the runtime serves the
|
||||
// PREVIOUS manifest indefinitely while reporting
|
||||
// state "ok". That is worse than the original ENOENT bug,
|
||||
// slice started on, because it looks healthy.
|
||||
//
|
||||
// No arrangement of watches fixes the second case: the watch is not wrong, it
|
||||
// is watching a directory that is no longer the one at that path. So the
|
||||
// backstop is not a better watch, it is to stop trusting watches for
|
||||
// correctness — stat the path we actually serve and compare identity.
|
||||
//
|
||||
// One stat per second. The watch still provides the fast path; this bounds the
|
||||
// worst case for every replacement shape, including ones not thought of here.
|
||||
// unref'd so it never holds the process open.
|
||||
function integrityCheck() {
|
||||
// Every source, not just the package's. The consumer manifest is the one a
|
||||
// real deployment actually redeploys, so it needs this floor at least as much.
|
||||
for (const src of sources()) {
|
||||
const current = signatureOf(src.path);
|
||||
if (current === src.loadedSignature) continue;
|
||||
|
||||
// Say so BEFORE correcting it. A silent correction rebuilds this slice's
|
||||
// own pathology one level up: on a clean directory swap the source goes
|
||||
// ok -> ok, so no recovery is reported and nothing distinguishes "the watch
|
||||
// saw it" from "the watch missed it and the floor caught it".
|
||||
const wasHealthy = src.state.state === "ok";
|
||||
src.state.integrityReloads += 1;
|
||||
src.state.lastIntegrityReloadAt = new Date().toISOString();
|
||||
console.log(
|
||||
`manifest REPLACED: ${src.path} — the file at this path is not the one ` +
|
||||
`loaded (was ${wasHealthy ? "serving normally" : `"${src.state.state}"`}); ` +
|
||||
`no watch event fired, caught by the integrity guard ` +
|
||||
`(integrity reload #${src.state.integrityReloads})`
|
||||
);
|
||||
|
||||
watchForChanges(); // the directory may be a different inode now
|
||||
loadSource(src);
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(integrityCheck, 1000).unref?.();
|
||||
|
||||
setInterval(() => { for (const res of sseClients) { try { res.write(`: heartbeat\n\n`); } catch {} } }, 25000).unref();
|
||||
|
||||
// ---- files: read-only, root-pinned ----------------------------------------
|
||||
// Containment is enforced on the REAL path (symlinks fully resolved), not the
|
||||
// lexical one: a symlink under the root that resolves outside it is refused.
|
||||
// (A lexical startsWith check follows a symlink straight out of the root.)
|
||||
const insideRoot = (p) => {
|
||||
let real, rootReal;
|
||||
try { real = fs.realpathSync(p); rootReal = fs.realpathSync(FILES_ROOT); } catch { return null; }
|
||||
return real === rootReal || real.startsWith(rootReal + path.sep) ? real : null;
|
||||
};
|
||||
const FILE_KINDS = { image: /\.(png|jpe?g|gif|webp|svg)$/i, video: /\.(mp4|mov|webm)$/i,
|
||||
audio: /\.(mp3|wav|m4a|flac)$/i, markdown: /\.(md|markdown)$/i, html: /\.html?$/i,
|
||||
text: /\.(txt|json|jsonl|yaml|yml|mjs|js|ts|css|py|sh|toml|csv)$/i };
|
||||
const fileKind = (name) => Object.entries(FILE_KINDS).find(([, re]) => re.test(name))?.[0] || "other";
|
||||
|
||||
function filesTree(dir) {
|
||||
const r = dir ? insideRoot(dir) : insideRoot(FILES_ROOT);
|
||||
if (!r || !fs.existsSync(r)) return null;
|
||||
const dirs = [], files = [];
|
||||
for (const name of fs.readdirSync(r).sort()) {
|
||||
if (name.startsWith(".")) continue;
|
||||
const full = path.join(r, name);
|
||||
const real = insideRoot(full); // symlink resolving outside the root: omit
|
||||
if (!real) continue;
|
||||
let st; try { st = fs.statSync(full); } catch { continue; }
|
||||
if (st.isDirectory()) dirs.push({ name, path: full });
|
||||
else files.push({ name, path: full, size: st.size, mtime: st.mtimeMs, kind: fileKind(name) });
|
||||
}
|
||||
return { path: r, dirs, files };
|
||||
}
|
||||
function filesSearch(q) {
|
||||
const needle = q.toLowerCase(), hits = [];
|
||||
const seen = new Set(); // real paths already walked — symlink-cycle safety
|
||||
const walk = (dir, depth) => {
|
||||
if (hits.length >= 60 || depth > 6) return;
|
||||
const realDir = insideRoot(dir);
|
||||
if (!realDir || seen.has(realDir)) return;
|
||||
seen.add(realDir);
|
||||
let names; try { names = fs.readdirSync(realDir); } catch { return; }
|
||||
for (const name of names) {
|
||||
if (name.startsWith(".")) continue;
|
||||
const full = path.join(realDir, name);
|
||||
if (!insideRoot(full)) continue; // escaping symlink: omit
|
||||
let st; try { st = fs.statSync(full); } catch { continue; }
|
||||
if (st.isDirectory()) walk(full, depth + 1);
|
||||
else if (name.toLowerCase().includes(needle)) hits.push({ name, path: full, kind: fileKind(name) });
|
||||
}
|
||||
};
|
||||
walk(FILES_ROOT, 0);
|
||||
return hits.slice(0, 60);
|
||||
}
|
||||
|
||||
// ---- HTTP ------------------------------------------------------------------
|
||||
// ---- init: everything above is declarations -------------------------------
|
||||
// Ordering here is a correctness property, not a style preference. This block
|
||||
// sits after EVERY module binding — verified by a test, not by reading — so
|
||||
// startup cannot land in a temporal dead zone no matter what these functions
|
||||
// grow to touch later.
|
||||
//
|
||||
// The weaker arrangement (init partway down, "safe" because the current call
|
||||
// graph happens not to reach the later bindings) is what produced two TDZ
|
||||
// crashes in this file already. The second was found by QA after the first was
|
||||
// "fixed" by hoisting the single binding that tripped. Add new startup work
|
||||
// HERE, not next to the function it calls.
|
||||
watchForChanges();
|
||||
loadManifest();
|
||||
|
||||
http.createServer((req, res) => {
|
||||
const u = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
||||
try {
|
||||
if (u.pathname === "/api/contract") {
|
||||
return sendJson(res, 200, {
|
||||
contractVersion: CONTRACT_VERSION,
|
||||
runtime: { name: "openrig-studio", flavor: "reference-fixture" },
|
||||
capabilities: CAPABILITIES,
|
||||
manifest: manifestReport(),
|
||||
});
|
||||
}
|
||||
if (u.pathname === "/api/factory/state") return sendJson(res, 200, factoryState());
|
||||
if (u.pathname === "/api/events") {
|
||||
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
|
||||
res.write(`: connected\n\n`);
|
||||
sseClients.add(res);
|
||||
req.on("close", () => sseClients.delete(res));
|
||||
return;
|
||||
}
|
||||
if (u.pathname === "/api/files/tree") {
|
||||
const t = filesTree(u.searchParams.get("dir"));
|
||||
return t ? sendJson(res, 200, { ok: true, ...t })
|
||||
: sendJson(res, 400, { ok: false, error: "path outside the files root — files verbs are pinned to the runtime's files-root" });
|
||||
}
|
||||
if (u.pathname === "/api/files/read") {
|
||||
const r = insideRoot(u.searchParams.get("path") || "");
|
||||
if (!r || !fs.existsSync(r) || fs.statSync(r).isDirectory())
|
||||
return sendJson(res, 404, { ok: false, error: "not found or outside the files root" });
|
||||
const kind = fileKind(r);
|
||||
if (["markdown", "text", "html"].includes(kind) || (kind === "other" && fs.statSync(r).size < 512 * 1024))
|
||||
return sendJson(res, 200, { ok: true, kind: kind === "other" ? "text" : kind,
|
||||
content: fs.readFileSync(r, "utf8"), mtime: fs.statSync(r).mtimeMs });
|
||||
return sendJson(res, 200, { ok: true, kind, raw: "/api/files/raw?path=" + encodeURIComponent(r), mtime: fs.statSync(r).mtimeMs });
|
||||
}
|
||||
if (u.pathname === "/api/files/raw") {
|
||||
const r = insideRoot(u.searchParams.get("path") || "");
|
||||
if (!r || !fs.existsSync(r)) { res.writeHead(404); return res.end(); }
|
||||
res.writeHead(200, { "content-type": TYPES[path.extname(r).toLowerCase()] || "application/octet-stream",
|
||||
"content-length": fs.statSync(r).size, "cache-control": "no-store" });
|
||||
return fs.createReadStream(r).pipe(res);
|
||||
}
|
||||
if (u.pathname === "/api/files/search") {
|
||||
const q = (u.searchParams.get("q") || "").trim();
|
||||
return sendJson(res, 200, { ok: true, hits: q.length < 2 ? [] : filesSearch(q) });
|
||||
}
|
||||
if (u.pathname.startsWith("/api/")) {
|
||||
return sendJson(res, 404, { ok: false,
|
||||
error: `no such contract route: ${u.pathname} — see GET /api/contract for capabilities and contract/runtime-api.md for the verb set` });
|
||||
}
|
||||
} catch (e) { return sendJson(res, 500, { ok: false, error: String(e.message || e) }); }
|
||||
|
||||
// the manifest is served as its VALIDATED projection, not the raw file —
|
||||
// this is where "invalid rows are excluded from the served rail" is true.
|
||||
// The raw file stays on disk for editing; consumers see valid rows only,
|
||||
// and the validation report lives at /api/contract.
|
||||
if (u.pathname === "/surfaces.json") {
|
||||
const doc = packageSource.result.doc && typeof packageSource.result.doc === "object" ? packageSource.result.doc : {};
|
||||
return sendJson(res, 200, { ...doc, surfaces: mergedSurfaces().rows });
|
||||
}
|
||||
|
||||
// static: shell.html at /, surfaces + assets from the app dir — and, when a
|
||||
// consumer overlay is configured, from the consumer's dir FIRST so that a row
|
||||
// and its page cannot disagree about which file wins.
|
||||
//
|
||||
// Containment is enforced on the REAL path for both roots. The overlay is a
|
||||
// new file-serving root, and a new root that skips the containment the files
|
||||
// verbs already have would be the one place traversal works.
|
||||
const rel = (u.pathname === "/" ? "shell.html" : u.pathname).replace(/^\/+/, "");
|
||||
// Resolve `sub` under `root`, refusing anything that escapes it. Containment
|
||||
// is checked on the REAL path, so a symlink inside the root that points out
|
||||
// of it is refused rather than followed.
|
||||
const within = (root, sub) => {
|
||||
const candidate = path.join(root, path.normalize(sub));
|
||||
let real, realRoot;
|
||||
try { real = fs.realpathSync(candidate); realRoot = fs.realpathSync(root); } catch { return null; }
|
||||
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return null;
|
||||
if (fs.statSync(real).isDirectory()) return null;
|
||||
return real;
|
||||
};
|
||||
|
||||
// Which source owns this URL is decided by the VALIDATED, MERGED rows — not
|
||||
// by which directory happens to contain a matching file.
|
||||
//
|
||||
// Serving the overlay path-first made it a public static root: any file under
|
||||
// it answered /surfaces/<name> whether or not a row registered it, so an
|
||||
// unregistered consumer file silently outranked a registered package page and
|
||||
// an unrelated file in that directory was published. It also let row
|
||||
// ownership and page ownership disagree, which is exactly what the id-based,
|
||||
// warned shadow semantics exist to prevent.
|
||||
const ownedBy = OVERLAY_DIR ? mergedSurfaces().owner.get(u.pathname) : undefined;
|
||||
|
||||
if (ownedBy === "consumer") {
|
||||
const file = within(OVERLAY_DIR, rel.slice("surfaces/".length));
|
||||
if (!file) {
|
||||
// NO cross-owner fallback. A registered consumer row whose page is
|
||||
// missing must fail as itself — quietly serving the package's bytes
|
||||
// under the consumer's row would be a lie about what is being served.
|
||||
res.writeHead(404, { "content-type": "text/plain" });
|
||||
return res.end(
|
||||
`not found: ${u.pathname}\n` +
|
||||
`a consumer surface is registered for this path but its page is missing from ${OVERLAY_DIR}\n` +
|
||||
`(the package's page is NOT substituted — registration and file must agree)`
|
||||
);
|
||||
}
|
||||
res.writeHead(200, { "content-type": TYPES[path.extname(file)] || "application/octet-stream", "cache-control": "no-store" });
|
||||
return res.end(fs.readFileSync(file));
|
||||
}
|
||||
|
||||
// With the seam active, an UNDECLARED file under /surfaces/ is unreachable.
|
||||
// Rejecting a contaminated ROW while still serving its PAGE would leave the
|
||||
// artifact half-published: off the rail but fetchable by anyone who knows the
|
||||
// name, which is how a "removed" surface keeps working. It is warned in the
|
||||
// manifest report, so it is visible rather than merely absent.
|
||||
//
|
||||
// Restricted to /surfaces/ deliberately: the shell and package assets resolve
|
||||
// as they always have, and with no overlay configured nothing here changes.
|
||||
if (OVERLAY_DIR && rel.startsWith("surfaces/") && ownedBy === undefined) {
|
||||
res.writeHead(404, { "content-type": "text/plain" });
|
||||
return res.end(
|
||||
`not found: ${u.pathname}\n` +
|
||||
`no declared surface claims this path — the runtime serves only the SDK's own declared ` +
|
||||
`surfaces plus your overlay-declared ones\n` +
|
||||
`(if this file is in the installed package, it is undeclared content; see manifest.warnings ` +
|
||||
`at /api/contract)`
|
||||
);
|
||||
}
|
||||
|
||||
// Everything else — the shell, package surfaces, package assets — resolves
|
||||
// inside the package exactly as it did before the seam existed.
|
||||
const file = within(HERE, rel);
|
||||
if (!file) {
|
||||
res.writeHead(404, { "content-type": "text/plain" });
|
||||
return res.end(`not found: ${u.pathname}\n(surfaces live under /surfaces/, contract meta at /api/contract)`);
|
||||
}
|
||||
res.writeHead(200, { "content-type": TYPES[path.extname(file)] || "application/octet-stream", "cache-control": "no-store" });
|
||||
res.end(fs.readFileSync(file));
|
||||
}).listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`openrig studio runtime: http://127.0.0.1:${PORT}/ (contract v${CONTRACT_VERSION})`);
|
||||
console.log(`fixtures: ${FIXTURES}`);
|
||||
const report = manifestReport();
|
||||
if (OVERLAY_DIR) console.log(`consumer surfaces: ${OVERLAY_DIR} (repo-owned; nothing is written into the package)`);
|
||||
if (report.errors.length) console.error(`manifest has ${report.errors.length} error(s) — serving ${report.surfaces} valid surface(s); see /api/contract`);
|
||||
});
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>openrig studio</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #121214; --panel: rgba(255,255,255,.025); --edge: rgba(255,255,255,.10);
|
||||
--edge2: rgba(255,255,255,.16); --bone: rgba(255,255,255,.95); --dim: rgba(255,255,255,.52);
|
||||
--body: rgba(255,255,255,.68); --faint: rgba(255,255,255,.38); --red: #ff6b5a;
|
||||
--sans: Helvetica, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { color-scheme: dark; }
|
||||
html, body { height: 100%; margin: 0; background: var(--ink); font-family: var(--sans); }
|
||||
#app { display: flex; height: 100vh; }
|
||||
#rail { width: 212px; min-width: 212px; background: var(--panel); border-right: 1px solid var(--edge);
|
||||
display: flex; flex-direction: column; }
|
||||
#rail-head { padding: 20px 16px 10px; color: var(--bone); font-weight: 700; letter-spacing: 4px; font-size: 13px; }
|
||||
#rail-head small { display: block; color: var(--faint); font-weight: 400; letter-spacing: .14em;
|
||||
font-size: 9.5px; margin-top: 5px; text-transform: uppercase; }
|
||||
#tabs { flex: 1; overflow-y: auto; padding: 6px 8px; }
|
||||
.tab { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 10px; margin: 2px 0;
|
||||
border: 1px solid transparent; border-radius: 2px; background: none; color: var(--body); cursor: pointer;
|
||||
font: inherit; font-size: 12.5px; text-align: left; }
|
||||
.tab:hover { color: var(--bone); border-color: var(--edge2); }
|
||||
.tab.active { color: #111; background: var(--bone); border-color: var(--bone); font-weight: 600; }
|
||||
.tab .glyph { font-size: 14px; width: 18px; text-align: center; }
|
||||
.tab .dot { margin-left: auto; width: 6px; height: 6px; border-radius: 50%; background: var(--edge2); flex: none; }
|
||||
.tab .dot.live { background: var(--bone); box-shadow: 0 0 6px rgba(255,255,255,.5); }
|
||||
.tab.active .dot.live { background: #111; }
|
||||
.tab .dot.down { background: var(--red); }
|
||||
#manifest-health { padding: 8px 16px; font-size: 10px; letter-spacing: .06em; color: var(--faint); }
|
||||
#manifest-health.bad { color: var(--red); }
|
||||
#rail-foot { padding: 12px 16px; border-top: 1px solid var(--edge); color: var(--faint);
|
||||
font-size: 10px; line-height: 1.6; }
|
||||
#stage { flex: 1; position: relative; background: var(--ink); }
|
||||
#stage iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: var(--ink); }
|
||||
#stage iframe.hidden { visibility: hidden; pointer-events: none; }
|
||||
#empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
color: var(--faint); font-size: 11px; letter-spacing: .2em; text-transform: uppercase; }
|
||||
#agents { width: 320px; min-width: 320px; display: none; flex-direction: column;
|
||||
border-left: 1px solid var(--edge); background: var(--panel); }
|
||||
#agents.open { display: flex; }
|
||||
#agents-head { display: flex; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--edge); }
|
||||
#agents-head .word { color: var(--bone); font-size: 9.5px; letter-spacing: .22em; font-weight: 700; cursor: pointer; }
|
||||
#agents-body { padding: 14px; color: var(--body); font-size: 12px; line-height: 1.6; }
|
||||
#agents-body .seatrow { padding: 8px 10px; border: 1px solid var(--edge2); border-radius: 2px; margin-bottom: 6px; }
|
||||
#agents-body .honest { color: var(--faint); font-size: 10.5px; margin-top: 10px; }
|
||||
#agentToggle { position: absolute; right: 14px; top: 14px; z-index: 40; background: var(--bone);
|
||||
border: none; border-radius: 2px; color: #111; font: inherit; font-size: 9.5px; letter-spacing: .18em;
|
||||
text-transform: uppercase; font-weight: 600; padding: 9px 16px; cursor: pointer; }
|
||||
/* hideAgents rows suppress the sidebar toggle while active (shell-protocol) */
|
||||
body.hide-agents #agentToggle { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<nav id="rail">
|
||||
<div id="rail-head">STUDIO<small>openrig studio · contract v0.1</small></div>
|
||||
<div id="tabs"></div>
|
||||
<div id="manifest-health"></div>
|
||||
<div id="rail-foot">surfaces are rooms.<br/>this is the hallway.</div>
|
||||
</nav>
|
||||
<main id="stage"><div id="empty">no surfaces in the manifest yet</div>
|
||||
<button id="agentToggle" title="the seat roster">AGENTS ▸</button>
|
||||
</main>
|
||||
<aside id="agents">
|
||||
<div id="agents-head"><span class="word" id="agentsWord">AGENTS ▾</span></div>
|
||||
<div id="agents-body"></div>
|
||||
</aside>
|
||||
</div>
|
||||
<script>
|
||||
(async () => {
|
||||
// --- contract meta first: the shell itself is a contract consumer -------
|
||||
let meta = null;
|
||||
try { meta = await (await fetch("/api/contract", { cache: "no-store" })).json(); } catch {}
|
||||
const health = document.getElementById("manifest-health");
|
||||
if (!meta) { health.className = "bad"; health.textContent = "runtime unreachable"; }
|
||||
else if (!meta.manifest.ok) {
|
||||
health.className = "bad";
|
||||
health.textContent = `manifest: ${meta.manifest.errors.length} error(s) — see /api/contract`;
|
||||
} else if (meta.manifest.warnings.length) {
|
||||
health.textContent = `manifest ok · ${meta.manifest.warnings.length} warning(s)`;
|
||||
} else { health.textContent = "manifest ok"; }
|
||||
|
||||
// --- surfaces from the manifest (valid rows only are served) ------------
|
||||
let reg = { surfaces: [] };
|
||||
try { reg = await (await fetch("/surfaces.json", { cache: "no-store" })).json(); } catch {}
|
||||
// defense in depth: the runtime already serves valid rows only; still never
|
||||
// render a row that lacks a target (a path-less row would recurse into "/")
|
||||
const surfaces = (reg.surfaces || [])
|
||||
.filter((s) => s && s.id && (s.url || (typeof s.path === "string" && s.path.startsWith("/"))))
|
||||
.map((s) => s.url ? s : ({ ...s, url: location.origin + s.path }));
|
||||
|
||||
const tabsEl = document.getElementById("tabs");
|
||||
const stage = document.getElementById("stage");
|
||||
const frames = new Map();
|
||||
let activeId = null;
|
||||
const byId = (id) => surfaces.find((s) => s.id === id);
|
||||
|
||||
function activate(id, fromNav) {
|
||||
const s = byId(id); if (!s) return;
|
||||
if (!fromNav && activeId !== id) { try { history.pushState({ s: id }, "", "?s=" + encodeURIComponent(id)); } catch {} }
|
||||
if (s.popout) { window.open(s.url, "os-" + s.id); return; }
|
||||
activeId = id;
|
||||
document.getElementById("empty").style.display = "none";
|
||||
// unmountOnBlur rows release their frame when backgrounded (terminal-
|
||||
// style surfaces must not keep hidden attached clients alive)
|
||||
for (const [fid, f] of frames) {
|
||||
if (fid !== id && (byId(fid) || {}).unmountOnBlur) { f.remove(); frames.delete(fid); }
|
||||
}
|
||||
if (!frames.has(id)) {
|
||||
const f = document.createElement("iframe");
|
||||
f.src = s.url; f.title = s.name;
|
||||
stage.appendChild(f); frames.set(id, f);
|
||||
}
|
||||
for (const [fid, f] of frames) f.classList.toggle("hidden", fid !== id);
|
||||
for (const el of tabsEl.children) el.classList.toggle("active", el.dataset.id === id);
|
||||
document.body.classList.toggle("hide-agents", !!s.hideAgents);
|
||||
document.title = "openrig studio · " + s.name.toLowerCase();
|
||||
}
|
||||
|
||||
function renderTabs() {
|
||||
tabsEl.innerHTML = "";
|
||||
for (const s of surfaces) {
|
||||
const b = document.createElement("button");
|
||||
b.className = "tab"; b.dataset.id = s.id;
|
||||
b.innerHTML = `<span class="glyph">${s.glyph || "□"}</span><span>${s.name}</span><span class="dot" id="dot-${s.id}"></span>`;
|
||||
b.title = s.hint || "";
|
||||
b.onclick = () => activate(s.id);
|
||||
if (s.id === activeId) b.classList.add("active");
|
||||
tabsEl.appendChild(b);
|
||||
}
|
||||
}
|
||||
renderTabs();
|
||||
|
||||
// liveness dots: an opaque no-cors fetch succeeds iff something answers
|
||||
async function pulse() {
|
||||
await Promise.all(surfaces.map(async (s) => {
|
||||
const dot = document.getElementById("dot-" + s.id); if (!dot) return;
|
||||
try { await fetch(s.url, { mode: "no-cors", cache: "no-store", signal: AbortSignal.timeout(2500) });
|
||||
dot.className = "dot live"; } catch { dot.className = "dot down"; }
|
||||
}));
|
||||
}
|
||||
pulse(); setInterval(pulse, 15000);
|
||||
|
||||
// deep link
|
||||
const qs = new URLSearchParams(location.search).get("s");
|
||||
if (byId(qs)) activate(qs, true); else if (surfaces[0]) activate(surfaces[0].id, true);
|
||||
addEventListener("popstate", (e) => {
|
||||
const id = (e.state && e.state.s) || new URLSearchParams(location.search).get("s");
|
||||
if (id && byId(id)) activate(id, true);
|
||||
});
|
||||
|
||||
// --- the shell protocol: the ONLY surface->shell API --------------------
|
||||
addEventListener("message", (e) => {
|
||||
if (!e.data || typeof e.data !== "object") return;
|
||||
if (e.data.t === "open-surface" && byId(e.data.id)) activate(e.data.id);
|
||||
if (e.data.t === "open-agent") openAgents(e.data.seat);
|
||||
});
|
||||
|
||||
// --- agent sidebar: the contracted seat surface is roster + summon ------
|
||||
// This reference runtime has no live seat backing; the sidebar says so
|
||||
// honestly instead of pretending (a seat that looks attached but isn't
|
||||
// would be worse than none).
|
||||
const agents = document.getElementById("agents");
|
||||
const agentsBody = document.getElementById("agents-body");
|
||||
function openAgents(seat) {
|
||||
const roster = reg.chatSeats || [];
|
||||
agentsBody.innerHTML = (roster.length
|
||||
? roster.map((r) => `<div class="seatrow"${r.seat === seat ? ' style="border-color:rgba(255,255,255,.5)"' : ""}>${r.name || r.seat}<br/><span style="color:var(--faint);font-size:10px">${r.seat}</span></div>`).join("")
|
||||
: `<div>no seats in the manifest roster</div>`)
|
||||
+ `<div class="honest">reference runtime: seat rows are declared in the manifest; live seat attach requires a seat backend, which this fixture-backed runtime does not include. Summoning works; typing is runtime-internal.</div>`;
|
||||
agents.classList.add("open");
|
||||
document.getElementById("agentToggle").style.display = "none";
|
||||
}
|
||||
document.getElementById("agentToggle").onclick = () => openAgents();
|
||||
document.getElementById("agentsWord").onclick = () => {
|
||||
agents.classList.remove("open");
|
||||
document.getElementById("agentToggle").style.display = "";
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"_note": "Surface manifest \u2014 the registry the shell renders and the runtime validates (warn-first). Row schema: contract/surface-row.schema.json; field semantics: contract/manifest.md. surfaces[] is the app-facing population. chatSeats[] is runtime-owned roster state (rendered by the shell's agent sidebar; not part of the app-facing schema).",
|
||||
"surfaces": [
|
||||
{
|
||||
"id": "floor",
|
||||
"name": "FLOOR",
|
||||
"glyph": "●",
|
||||
"path": "/surfaces/floor.html",
|
||||
"hint": "Live rig-floor seats and queue activity"
|
||||
}
|
||||
],
|
||||
"chatSeats": [
|
||||
{
|
||||
"seat": "studio-lead@fixture",
|
||||
"name": "LEAD"
|
||||
}
|
||||
]
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1,206 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Rig Floor</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0b0e13;
|
||||
--panel: #121722;
|
||||
--line: #252c3a;
|
||||
--text: #f3f5f8;
|
||||
--muted: #929cad;
|
||||
--working: #47d18c;
|
||||
--idle: #6d9eff;
|
||||
--blocked: #ffb44a;
|
||||
--stalled: #ff795f;
|
||||
--down: #77808f;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at 80% 0%, #172032 0, transparent 34%), var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
main { width: min(1040px, 100%); margin: 0 auto; padding: 28px; }
|
||||
header { display: flex; align-items: end; justify-content: space-between; gap: 18px; margin-bottom: 24px; }
|
||||
.eyebrow { color: var(--working); font-size: 11px; letter-spacing: .16em; text-transform: uppercase; }
|
||||
h1 { margin: 4px 0 0; font: 600 clamp(28px, 5vw, 46px)/1.05 system-ui, sans-serif; letter-spacing: -.04em; }
|
||||
.connection { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
|
||||
.pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--blocked); box-shadow: 0 0 0 4px #ffb44a18; }
|
||||
.connection.live .pulse { background: var(--working); box-shadow: 0 0 0 4px #47d18c18; }
|
||||
.connection.polling .pulse { background: var(--idle); box-shadow: 0 0 0 4px #6d9eff18; }
|
||||
|
||||
.summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 20px; }
|
||||
.stat, .seat, .queue-item, .notice { border: 1px solid var(--line); background: color-mix(in srgb, var(--panel) 94%, transparent); border-radius: 12px; }
|
||||
.stat { padding: 14px 16px; }
|
||||
.stat b { display: block; font: 650 24px/1 system-ui, sans-serif; margin-bottom: 5px; }
|
||||
.stat span, .meta, .empty { color: var(--muted); font-size: 12px; }
|
||||
section { margin-top: 24px; }
|
||||
h2 { margin: 0 0 10px; color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.seats { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; }
|
||||
.seat { padding: 15px; position: relative; overflow: hidden; }
|
||||
.seat::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--state); }
|
||||
.seat-top { display: flex; justify-content: space-between; gap: 12px; align-items: start; }
|
||||
.seat-name { overflow-wrap: anywhere; font-weight: 650; }
|
||||
.badge { color: var(--state); border: 1px solid color-mix(in srgb, var(--state) 45%, transparent); border-radius: 999px; padding: 2px 7px; font-size: 10px; text-transform: uppercase; }
|
||||
.seat .meta { margin-top: 12px; display: flex; justify-content: space-between; gap: 8px; }
|
||||
.working { --state: var(--working); }
|
||||
.idle { --state: var(--idle); }
|
||||
.blocked { --state: var(--blocked); }
|
||||
.stalled { --state: var(--stalled); }
|
||||
.down { --state: var(--down); }
|
||||
.queue { display: grid; gap: 8px; }
|
||||
.queue-item { padding: 13px 15px; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 16px; }
|
||||
.queue-title { font-weight: 600; }
|
||||
.queue-state { color: var(--idle); font-size: 11px; text-transform: uppercase; }
|
||||
.queue-route { grid-column: 1 / -1; color: var(--muted); font-size: 11px; overflow-wrap: anywhere; }
|
||||
.notice { padding: 18px; color: var(--blocked); }
|
||||
footer { margin-top: 22px; color: var(--muted); font-size: 11px; }
|
||||
@media (max-width: 600px) {
|
||||
main { padding: 18px; }
|
||||
header { align-items: start; flex-direction: column; }
|
||||
.summary { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<div><div class="eyebrow">Live activity</div><h1 id="rig">Rig floor</h1></div>
|
||||
<div id="connection" class="connection"><span class="pulse"></span><span>connecting</span></div>
|
||||
</header>
|
||||
<div id="content"><div class="notice">Reading the runtime contract…</div></div>
|
||||
<footer id="updated"></footer>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const content = document.querySelector('#content');
|
||||
const rig = document.querySelector('#rig');
|
||||
const updated = document.querySelector('#updated');
|
||||
const connection = document.querySelector('#connection');
|
||||
let events;
|
||||
let pollTimer;
|
||||
let reconnectTimer;
|
||||
|
||||
const escapeHtml = (value) => String(value ?? '').replace(/[&<>'"]/g, char => ({
|
||||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'
|
||||
})[char]);
|
||||
|
||||
function setConnection(mode, label) {
|
||||
connection.className = `connection ${mode}`;
|
||||
connection.lastElementChild.textContent = label;
|
||||
}
|
||||
|
||||
function render(state) {
|
||||
if (!state.ok) {
|
||||
content.innerHTML = `<div class="notice">Data degraded: ${escapeHtml(state.degraded || 'unknown reason')}</div>`;
|
||||
rig.textContent = 'Rig floor';
|
||||
updated.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
rig.textContent = state.rig;
|
||||
const working = state.seats.filter(seat => seat.state === 'working').length;
|
||||
const attention = state.seats.filter(seat => ['blocked', 'stalled', 'down'].includes(seat.state)).length;
|
||||
const pending = state.queue.length;
|
||||
const seats = state.seats.map(seat => `
|
||||
<article class="seat ${escapeHtml(seat.state)}">
|
||||
<div class="seat-top">
|
||||
<div class="seat-name">${escapeHtml(seat.seat)}</div>
|
||||
<span class="badge">${escapeHtml(seat.state)}</span>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span>${escapeHtml(seat.pod)} / ${escapeHtml(seat.member)}</span>
|
||||
<span>${seat.ageMinutes == null ? 'age unknown' : `${escapeHtml(seat.ageMinutes)}m`}</span>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span>${escapeHtml(seat.lifecycle)}</span>
|
||||
<span>${escapeHtml(seat.pendingWork)} pending</span>
|
||||
</div>
|
||||
</article>`).join('') || '<div class="empty">No seats reported.</div>';
|
||||
const queue = state.queue.map(item => `
|
||||
<article class="queue-item">
|
||||
<div class="queue-title">${escapeHtml(item.title)}</div>
|
||||
<div class="queue-state">${escapeHtml(item.state)}</div>
|
||||
<div class="queue-route">${escapeHtml(item.source)} → ${escapeHtml(item.destination)}</div>
|
||||
</article>`).join('') || '<div class="empty">Queue is clear.</div>';
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="summary">
|
||||
<div class="stat"><b>${working}</b><span>working now</span></div>
|
||||
<div class="stat"><b>${attention}</b><span>need attention</span></div>
|
||||
<div class="stat"><b>${pending}</b><span>queue items</span></div>
|
||||
</div>
|
||||
<section><h2>Seats · ${state.seats.length}</h2><div class="seats">${seats}</div></section>
|
||||
<section><h2>Queue · ${state.queue.length}</h2><div class="queue">${queue}</div></section>`;
|
||||
updated.textContent = `Runtime snapshot · ${new Date(state.generatedAt).toLocaleString()}`;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const response = await fetch('/api/factory/state', { cache: 'no-store' });
|
||||
const state = await response.json();
|
||||
render(state);
|
||||
} catch (error) {
|
||||
content.innerHTML = `<div class="notice">Unable to read floor state: ${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = setInterval(refresh, 15000);
|
||||
setConnection('polling', 'polling fallback');
|
||||
}
|
||||
|
||||
function connectEvents() {
|
||||
clearTimeout(reconnectTimer);
|
||||
if (events) events.close();
|
||||
events = new EventSource('/api/events');
|
||||
events.onopen = () => {
|
||||
clearInterval(pollTimer);
|
||||
setConnection('live', 'live signal');
|
||||
refresh();
|
||||
};
|
||||
events.onmessage = () => refresh();
|
||||
events.addEventListener('degraded', () => {
|
||||
events.close();
|
||||
startPolling();
|
||||
reconnectTimer = setTimeout(connectEvents, 15000);
|
||||
});
|
||||
events.onerror = () => {
|
||||
events.close();
|
||||
startPolling();
|
||||
reconnectTimer = setTimeout(connectEvents, 15000);
|
||||
};
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
const response = await fetch('/api/contract', { cache: 'no-store' });
|
||||
const contract = await response.json();
|
||||
const needed = ['observe.factory-state', 'stream.events'];
|
||||
const missing = needed.filter(capability => !contract.capabilities.includes(capability));
|
||||
// contract-meta compatibility rule: capabilities present + same major.
|
||||
// (Exact-match rejects contract-compatible minor runtimes.)
|
||||
const major = String(contract.contractVersion ?? '').split('.')[0];
|
||||
if (major !== '0' || missing.length) {
|
||||
throw new Error(`Unsupported runtime contract ${contract.contractVersion}${missing.length ? `; missing ${missing.join(', ')}` : ''}`);
|
||||
}
|
||||
await refresh();
|
||||
connectEvents();
|
||||
} catch (error) {
|
||||
content.innerHTML = `<div class="notice">Cannot start: ${escapeHtml(error.message)}</div>`;
|
||||
startPolling();
|
||||
}
|
||||
}
|
||||
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,102 @@
|
||||
# Contract meta — versioning, stability, and how to read this contract
|
||||
|
||||
contractVersion: 0.1
|
||||
|
||||
This directory IS the openrig studio SDK contract. If you are a coding agent
|
||||
building a surface: read these four documents plus `surface-row.schema.json`,
|
||||
then build. Nothing outside this directory and the running runtime is required.
|
||||
|
||||
## The four documents
|
||||
|
||||
| Document | What it defines |
|
||||
|---|---|
|
||||
| `contract-meta.md` (this file) | Version, stability classes, what counts as breaking |
|
||||
| `manifest.md` | The surface manifest: how a surface registers |
|
||||
| `shell-protocol.md` | The shell runtime: lifecycle, URL rules, the postMessage API |
|
||||
| `runtime-api.md` | The HTTP verb set: routes, shapes, failure semantics |
|
||||
|
||||
## Discovering the contract at runtime
|
||||
|
||||
`GET /api/contract` returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"contractVersion": "0.1",
|
||||
"runtime": { "name": "openrig-studio", "flavor": "reference-fixture" },
|
||||
"capabilities": ["contract.meta", "observe.factory-state", "stream.events", "files.read", "shell.protocol"],
|
||||
"manifest": {
|
||||
"ok": true, "errors": [], "warnings": [], "surfaces": 1,
|
||||
"state": "ok",
|
||||
"lastLoadedAt": "<ISO timestamp of the last successful load>",
|
||||
"reloads": 1,
|
||||
"recoveries": 0,
|
||||
"lastRecoveryAt": null,
|
||||
"integrityReloads": 0,
|
||||
"lastIntegrityReloadAt": null,
|
||||
"consumer": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `contractVersion` — the version of this contract the runtime implements.
|
||||
- `capabilities` — which contract namespaces this runtime serves. Check before use.
|
||||
- `manifest` — the live validation report for `surfaces.json` (see `manifest.md`).
|
||||
`surfaces` is the count of valid rows currently served; it changes as the
|
||||
manifest changes.
|
||||
|
||||
This endpoint is the first thing to read against a live runtime.
|
||||
|
||||
## Stability classes
|
||||
|
||||
Every route, field, and message type in this contract carries one of:
|
||||
|
||||
- **stable** — changes only additively while the contract is 0.x. Removal or
|
||||
rename requires a major version bump with a deprecation note.
|
||||
- **provisional** — may change with a minor bump. Usable, but re-check on
|
||||
version changes. Marked in-line wherever it appears.
|
||||
- **runtime-internal** — exists in a runtime but is NOT contract. Do not build
|
||||
on it. Listed in `runtime-api.md` so you are warned at the point of
|
||||
temptation.
|
||||
|
||||
## What counts as a breaking change
|
||||
|
||||
- Removing or renaming a route, response field, manifest field, or postMessage type.
|
||||
- Changing a response shape non-additively.
|
||||
- Changing an error or conflict contract.
|
||||
- Changing URL-resolution or surface-lifecycle semantics.
|
||||
|
||||
Additive fields and new routes are non-breaking. Breaking changes move
|
||||
`contractVersion` and are named in a changelog entry.
|
||||
|
||||
## How a surface should decide compatibility
|
||||
|
||||
Check two things at boot, in this order:
|
||||
|
||||
1. **Capabilities**: every namespace you use appears in `capabilities`. A
|
||||
missing capability means this runtime cannot serve you — fail with a
|
||||
visible message.
|
||||
2. **Version**: parse `contractVersion` as `major.minor`. Within the same
|
||||
major version, minor bumps are additive for **stable**-marked items — a
|
||||
surface built on stable items of 0.1 keeps working on 0.2. A **major**
|
||||
change may break you: fail visibly rather than guessing.
|
||||
|
||||
Exact-match checks (`contractVersion !== "0.1"`) are safe but over-strict:
|
||||
they refuse runtimes that are contractually guaranteed to work. Prefer
|
||||
same-major + capabilities-present. Surfaces using **provisional** items
|
||||
should re-check them on any version change, including minor.
|
||||
|
||||
## Design stance (why this contract is shaped this way)
|
||||
|
||||
- **This contract is the boundary.** The runtime may internally consult other
|
||||
systems; surfaces talk only to this API. Anything a surface needs from the
|
||||
wider system arrives through a versioned verb here — never by reaching
|
||||
around the runtime. A surface that routes around the boundary is out of
|
||||
contract by definition.
|
||||
- **Declared-independent.** The shapes here are designed and owned by this
|
||||
contract; they are not mirrors of any other system's API. Backing systems
|
||||
can change without this contract changing.
|
||||
- **Honest failure.** When something is wrong it must look wrong: degraded
|
||||
states are named in responses, malformed manifests are reported at
|
||||
`/api/contract`, and nothing silently pretends health.
|
||||
- **No credential ever appears in a contract response.** Auth state, tokens,
|
||||
key paths, fingerprints — none of it crosses this boundary.
|
||||
@@ -0,0 +1,169 @@
|
||||
# The surface manifest — how a surface registers
|
||||
|
||||
contractVersion: 0.1
|
||||
|
||||
The manifest is `app/surfaces.json`. The shell renders its rail from it; the
|
||||
runtime validates it. One row in `surfaces[]` = one surface = one door.
|
||||
|
||||
There are two registration paths, and which one you want depends on whose
|
||||
surface it is:
|
||||
|
||||
| You are… | Use |
|
||||
|---|---|
|
||||
| adding a surface **to this SDK itself** | *Registering a surface* below |
|
||||
| **building a studio ON the SDK** and adding your own surface | *Registering surfaces as a consumer* below |
|
||||
|
||||
If you installed this SDK as a dependency, you are the second case. Its `app/`
|
||||
directory is inside `node_modules` — gitignored, destroyed by `npm ci`, and
|
||||
carried by any copy of the tree. **Do not put your surfaces there.**
|
||||
|
||||
## Registering a surface (the whole procedure)
|
||||
|
||||
This is the path for surfaces owned by the SDK itself.
|
||||
|
||||
1. Put your surface page under `app/surfaces/` (e.g. `app/surfaces/mine.html`).
|
||||
It is served same-origin at `/surfaces/mine.html`.
|
||||
2. Add one row to `surfaces[]` in `app/surfaces.json`:
|
||||
|
||||
```json
|
||||
{ "id": "mine", "name": "MINE", "glyph": "◆", "path": "/surfaces/mine.html",
|
||||
"hint": "one line about what this surface is" }
|
||||
```
|
||||
|
||||
3. Reload the shell. Your surface appears on the rail. That is the entire
|
||||
registration surface — there is no build step and no other file to touch.
|
||||
|
||||
**Rediscovery semantics (v0.1):** the **runtime** re-validates the manifest
|
||||
live whenever the file changes — `GET /api/contract` reflects an edit within
|
||||
about a second, no restart needed. The **shell**, however, reads the manifest
|
||||
once at load: an already-open shell tab does **not** rediscover new or changed
|
||||
rows — reload the shell tab to see rail changes. Live rail rediscovery is a
|
||||
candidate contract addition, not an unstated current behavior.
|
||||
|
||||
Field semantics and stability marks: `surface-row.schema.json` (each property
|
||||
carries a `description` and an `x-stability` mark). Required: `id` (unique),
|
||||
`name`, and exactly one of `path` (must start with `/`) or `url`.
|
||||
|
||||
## Registering surfaces as a consumer (stable)
|
||||
|
||||
If you are building a studio **on** this SDK, your surfaces are yours: they live
|
||||
in **your** repository, are versioned by **you**, and are never written into the
|
||||
installed package.
|
||||
|
||||
Point the runtime at a directory you own:
|
||||
|
||||
```sh
|
||||
node node_modules/@openrig/studio/app/serve-studio.mjs --surfaces ./surfaces
|
||||
# or
|
||||
OPENRIG_STUDIO_SURFACES=./surfaces npm start
|
||||
```
|
||||
|
||||
That directory holds your pages **and** your own `surfaces.json`, using exactly
|
||||
the row schema documented here:
|
||||
|
||||
```
|
||||
surfaces/
|
||||
surfaces.json { "surfaces": [ { "id": "mine", "name": "MINE", "path": "/surfaces/mine.html" } ] }
|
||||
mine.html
|
||||
```
|
||||
|
||||
Your pages are served at `/surfaces/<file>`, the same URLs as any other surface.
|
||||
|
||||
**Merge and precedence.** The rail is the SDK's own surfaces followed by yours.
|
||||
If you reuse an id, **your row wins** — that is how you deliberately replace a
|
||||
stock surface — and the runtime emits a warning naming the shadowed id, so a
|
||||
replacement is never silent.
|
||||
|
||||
**Serving follows registration, not the filesystem (stable).** A file in your
|
||||
overlay directory is served only when a **valid row registers it**. Putting a
|
||||
file there does not publish it: an unregistered file is a 404, and it never
|
||||
outranks a registered package page. If you register a row whose page is missing,
|
||||
that path returns **404 naming the problem** — the package's page is not
|
||||
substituted underneath your row, because then the rail and the bytes would
|
||||
disagree about whose surface you are looking at. Replacing a stock surface is
|
||||
done by **id**, which warns; it is not done by filename, which would be silent.
|
||||
|
||||
**With the seam active, the runtime serves exactly what is DECLARED (stable).**
|
||||
That is: the SDK's own declared surfaces, plus the surfaces your overlay
|
||||
declares, and nothing else.
|
||||
|
||||
The SDK's own set is declared **in its runtime source**, not in
|
||||
`app/surfaces.json`. That is deliberate. The failure this seam removes is
|
||||
contamination *of the installed package* — a copied tree, a stale
|
||||
materialisation, a dependency writing into `node_modules` — and every one of
|
||||
those mutates the manifest and the surfaces directory. Rooting the SDK's
|
||||
authority in the file the contamination writes to would make an injected row
|
||||
authoritative by construction.
|
||||
|
||||
So, when an overlay is configured:
|
||||
|
||||
- a row in the package manifest that the SDK does not declare as its own is
|
||||
**ignored for serving and reported in `manifest.warnings`**
|
||||
- a file under the package's `app/surfaces/` not backed by a served declared row
|
||||
is **not served and reported the same way**
|
||||
|
||||
Contamination is made **visible**, not silently dropped — an operator learns the
|
||||
installed package is dirty rather than merely not seeing a surface.
|
||||
|
||||
**Scope of that guarantee, stated so it is not read as more than it is.** It
|
||||
covers surface state written *into* an installed package. It does **not** cover
|
||||
a dependency that ships a **modified runtime source**: that is a supply-chain
|
||||
compromise, a different and higher-bar class, and it is already accepted by the
|
||||
act of installing the SDK at all.
|
||||
|
||||
**Zero config is unaffected.** With no overlay configured none of the above
|
||||
applies and the package manifest is served exactly as it always has been.
|
||||
|
||||
**Validation** is the same warn-first contract, applied per source. With an
|
||||
overlay configured, errors and warnings are prefixed with which manifest they
|
||||
came from (`package` / `consumer`), because a bare `surfaces[0]` no longer says
|
||||
enough. Without an overlay the messages are unchanged.
|
||||
|
||||
**Per-source health** appears at `GET /api/contract` under `manifest.consumer`
|
||||
— its own `state`, `reloads`, `recoveries` and `integrityReloads`, with the same
|
||||
meanings as the top-level fields (which describe the SDK's own manifest). It is
|
||||
`null` when no overlay is configured.
|
||||
|
||||
**Why the seam exists, not just how.** The registration path above is inside the
|
||||
package. In a consumer install that is `node_modules/@openrig/studio/app/` —
|
||||
gitignored, wiped by `npm ci`, and, worst of all, **carried by a copy**:
|
||||
duplicating a `node_modules` tree duplicates any surface state inside it, so a
|
||||
copied deployment can serve a healthy manifest with the wrong surfaces. Because
|
||||
consumer surfaces never live in the package, copying it cannot carry them and
|
||||
there is nothing to reconcile. The runtime writes nothing into your overlay
|
||||
directory and copies nothing out of it.
|
||||
|
||||
**Zero config stays zero config.** With no `--surfaces` and no
|
||||
`OPENRIG_STUDIO_SURFACES`, everything above is inert and the SDK's own surfaces
|
||||
behave exactly as they did before this seam existed. The overlay is opt-in.
|
||||
|
||||
## Validation — warn-first (stable behavior)
|
||||
|
||||
The runtime validates the manifest at startup and re-validates when the file
|
||||
changes. The behavior is **warn-first**:
|
||||
|
||||
- Rows that fail a required check are **excluded from the served rail** and
|
||||
named, row-by-row, in `GET /api/contract` → `manifest.errors[]` and in the
|
||||
runtime's startup log.
|
||||
- Unknown fields are **warnings**, never errors — forward-compatibility is
|
||||
deliberate.
|
||||
- The runtime keeps serving throughout. A malformed manifest never kills the
|
||||
runtime; it becomes visible instead.
|
||||
|
||||
Check `GET /api/contract` after editing the manifest. If your row does not
|
||||
appear on the rail, the reason is spelled out there.
|
||||
|
||||
## Populations that are not the app-facing schema
|
||||
|
||||
`surfaces.json` may also carry runtime-owned state:
|
||||
|
||||
- `chatSeats[]` — the agent-sidebar roster (`{seat, name}` rows). The shell
|
||||
renders it; surfaces may summon it (see `shell-protocol.md`). Surfaces do
|
||||
**not** write it, and it is not part of the surface-row schema.
|
||||
- `_note` — a human/agent-facing comment field. Ignored by the runtime.
|
||||
|
||||
## The example fixture posture
|
||||
|
||||
The reference runtime ships with one surface, FLOOR, which renders the fixture rig state. Your first surface's row goes beside it. The fixture data behind the runtime's API verbs
|
||||
(`fixtures/`) is already populated, so a new surface has real data to render
|
||||
on its first load.
|
||||
@@ -0,0 +1,116 @@
|
||||
# The runtime API — HTTP verbs, shapes, failure semantics
|
||||
|
||||
contractVersion: 0.1
|
||||
|
||||
Base: the runtime's own origin (default `http://127.0.0.1:8890`). All routes
|
||||
are GET unless noted. Start every integration by reading `GET /api/contract`
|
||||
(shape in `contract-meta.md`) and checking `capabilities`.
|
||||
|
||||
## Failure semantics (stable)
|
||||
|
||||
- **Degraded reads** return HTTP 200 with `{ "ok": false, "degraded": "<why>" }`
|
||||
— the runtime is healthy, the backing data is not. Render the degradation;
|
||||
do not retry-loop.
|
||||
- **Client errors** return 4xx with `{ "ok": false, "error": "<what> — <what to do>" }`.
|
||||
- **Unexpected errors** return 500 with `{ "ok": false, "error": "<message>" }`.
|
||||
- Unknown `/api/*` paths return 404 with an error naming where the verb set
|
||||
is documented.
|
||||
- No contract response ever contains a credential value, token, key path, or
|
||||
fingerprint.
|
||||
|
||||
## contract.meta
|
||||
|
||||
### `GET /api/contract` — stable
|
||||
Version, capabilities, and the manifest validation report. Documented in
|
||||
`contract-meta.md`. Read it first.
|
||||
|
||||
## observe
|
||||
|
||||
### `GET /api/factory/state` — stable
|
||||
The rig-floor envelope: who is working and what is queued. The shape is owned
|
||||
by this contract (declared here, field-by-field), not by any backing system.
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"rig": "<rig name>",
|
||||
"generatedAt": "<ISO timestamp, stamped fresh per read>",
|
||||
"seats": [
|
||||
{ "seat": "<session name>", "pod": "<pod id>", "member": "<member id>",
|
||||
"state": "working | idle | blocked | stalled | down",
|
||||
"ageMinutes": 0, "hasWork": false, "pendingWork": 0,
|
||||
"lifecycle": "<runtime lifecycle string>" }
|
||||
],
|
||||
"queue": [
|
||||
{ "id": "<item id>", "state": "<item state string>",
|
||||
"destination": "<session>", "source": "<session>",
|
||||
"tags": ["<tag>"], "updated": "<ISO timestamp>", "title": "<one line>" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `seats[].state` is a closed set (stable). `queue[].state` is an open string
|
||||
set (provisional) — render it, do not switch on it exhaustively.
|
||||
- `ageMinutes` is **whole minutes elapsed since the seat's most recent
|
||||
observed activity** (any runtime-visible action by that seat), rounded to
|
||||
the nearest minute, or `null` when no activity timestamp is known. It
|
||||
measures quietness, not uptime: a `working` seat can have a nonzero age.
|
||||
- Degraded shape: `{ "ok": false, "degraded": "<why>", "rig": null, "seats": [], "queue": [] }`.
|
||||
|
||||
## stream
|
||||
|
||||
### `GET /api/events` — stable (as a signal)
|
||||
Server-sent events. **Signal-only contract**: any received message means
|
||||
"state changed — re-fetch via the observe/files verbs." Event payload
|
||||
contents are unspecified in v0.1 and must not be parsed or switched on.
|
||||
|
||||
- **Event naming (closed set):** every change signal is a **default, unnamed
|
||||
`message` event** — an `EventSource.onmessage` handler receives all of
|
||||
them. The **only** named event in v0.1 is `degraded`. Stable runtimes will
|
||||
not introduce additional named events within 0.x; a new named event is a
|
||||
contract addition and arrives with a version change.
|
||||
- Comment frames (lines starting `:`) are keepalives; EventSource ignores
|
||||
them natively.
|
||||
- A `degraded` named event means the signal source is unhealthy: close and
|
||||
fall back to polling (cadence guidance in `shell-protocol.md`).
|
||||
- Reconnection is EventSource-native. Delivery is at-least-once-ish with no
|
||||
replay guarantee: after any reconnect, re-fetch once rather than assuming
|
||||
you missed nothing.
|
||||
|
||||
## files (read-only)
|
||||
|
||||
All paths are pinned inside the runtime's files root; anything outside it is
|
||||
refused with a 4xx naming the pin. There are no write verbs in this contract.
|
||||
|
||||
### `GET /api/files/tree?dir=<abs path>` — stable
|
||||
Without `dir`: the root listing. With `dir` (inside the root):
|
||||
`{ "ok": true, "path": "...", "dirs": [{name, path}], "files": [{name, path, size, mtime, kind}] }`.
|
||||
`kind` ∈ `image | video | audio | markdown | html | text | other`.
|
||||
|
||||
### `GET /api/files/read?path=<abs path>` — stable
|
||||
Text-like files: `{ "ok": true, "kind": "...", "content": "<utf8>", "mtime": <ms> }`.
|
||||
Binary/media files: `{ "ok": true, "kind": "...", "raw": "/api/files/raw?path=...", "mtime": <ms> }` —
|
||||
fetch the `raw` URL for bytes.
|
||||
|
||||
### `GET /api/files/raw?path=<abs path>` — stable
|
||||
The file's bytes with a best-effort content-type.
|
||||
|
||||
### `GET /api/files/search?q=<term>` — stable
|
||||
Case-insensitive filename search under the root:
|
||||
`{ "ok": true, "hits": [{name, path, kind}] }`. Bounded result count; queries
|
||||
under 2 characters return empty hits.
|
||||
|
||||
## runtime-internal — exists in some runtimes; DO NOT build on
|
||||
|
||||
Listed so you are warned at the point of temptation. None of these are
|
||||
contract, none are present in the reference runtime's public surface, and
|
||||
each becomes contract only through its own future security/ownership gate:
|
||||
|
||||
- files mutation (write / goto / roots overlays)
|
||||
- shell sidebar-arrangement persistence
|
||||
- typing text into an agent seat or terminal
|
||||
- OAuth / credential-connect flows
|
||||
- any verb that reaches a backing system's API directly
|
||||
|
||||
If your surface needs one of these, it needs a contract version that includes
|
||||
it — not a workaround.
|
||||
@@ -0,0 +1,95 @@
|
||||
# The shell protocol — lifecycle, URL rules, and the postMessage API
|
||||
|
||||
contractVersion: 0.1
|
||||
|
||||
The shell (`/`, served from `app/shell.html`) is the hallway: a rail of
|
||||
surfaces on the left, a stage that hosts one surface at a time in an iframe,
|
||||
and an agent sidebar. This document is what a surface may rely on.
|
||||
|
||||
## URL resolution (stable)
|
||||
|
||||
- A row with `path` is loaded same-origin: `{origin}{path}`.
|
||||
- A row with `url` is loaded as given (external surfaces).
|
||||
- Deep link: `?s=<id>` opens that surface directly and survives reload;
|
||||
the shell keeps the address bar in sync as the user switches surfaces.
|
||||
|
||||
## Surface lifecycle (stable)
|
||||
|
||||
- The stage iframe for a surface is created on **first visit** and **kept
|
||||
alive** when the user switches away — your surface keeps its state and its
|
||||
open connections while backgrounded.
|
||||
- Exception: rows with `unmountOnBlur: true` have their iframe **removed** on
|
||||
switch-away and recreated on return. Declare this if your surface holds an
|
||||
attachment that must not persist invisibly (terminal viewers are the
|
||||
canonical case: a hidden kept-alive client stays attached and fights the
|
||||
visible ones). Cost: your surface cold-starts on each return.
|
||||
- `popout: true` rows open in a new browser tab instead of the stage.
|
||||
- `hideAgents: true` suppresses the agent-sidebar toggle while your surface
|
||||
is active.
|
||||
|
||||
## Liveness (stable behavior, cosmetic result)
|
||||
|
||||
The shell polls every surface URL with an opaque no-cors fetch on an interval
|
||||
and renders a rail dot: answered = live, failed = down. Your surface needs no
|
||||
code for this — just answer HTTP.
|
||||
|
||||
## The postMessage API — the ONLY surface→shell channel (stable)
|
||||
|
||||
Send from inside your iframe with `parent.postMessage(msg, "*")`:
|
||||
|
||||
| Message | Effect |
|
||||
|---|---|
|
||||
| `{ "t": "open-surface", "id": "<surface-id>" }` | The shell activates that surface (same as a rail click). Unknown ids are ignored. |
|
||||
| `{ "t": "open-agent" }` | The shell opens the agent sidebar. |
|
||||
| `{ "t": "open-agent", "seat": "<seat-name>" }` | Opens the sidebar with that roster seat highlighted. Unknown seats open the sidebar unhighlighted. |
|
||||
|
||||
This message set is closed for v0.1: the shell ignores any other `t` value.
|
||||
New message types arrive as contract additions, never by surfaces inventing
|
||||
them.
|
||||
|
||||
## The agent sidebar (what "seat" means in v0.1)
|
||||
|
||||
The contracted seat surface is deliberately small:
|
||||
|
||||
- Seats are **declared** as `chatSeats[]` roster rows in the manifest.
|
||||
- A surface may **summon** the sidebar (`open-agent` above).
|
||||
|
||||
That is the whole contracted seat surface in v0.1. Typing into a seat and
|
||||
richer seat→surface channels exist in some runtimes as **runtime-internal**
|
||||
capability (see `runtime-api.md`) and are not contract. The reference runtime
|
||||
renders the roster and says honestly that no live seat backing is present.
|
||||
|
||||
## The change-signal loop (how a surface stays fresh)
|
||||
|
||||
The canonical read loop for a live surface:
|
||||
|
||||
1. Fetch what you need from the observe/files verbs (`runtime-api.md`).
|
||||
2. Open `GET /api/events` (SSE). On **any** message: re-fetch what you care
|
||||
about. Do not parse event payloads — the stream is a signal, not a data
|
||||
channel.
|
||||
3. If the stream errors or emits a `degraded` event: close it and fall back
|
||||
to polling, and keep retrying the stream when convenient.
|
||||
|
||||
**Freshness bound (fallback polling):** poll every **10–30 seconds** while the
|
||||
stream is down; **15 seconds is the recommended default**. Faster than 10s
|
||||
wastes work against a signal-driven runtime; slower than 30s reads as stale to
|
||||
a human watching the surface. When the stream reconnects, stop polling and
|
||||
re-fetch once.
|
||||
|
||||
This loop is the intended pattern; the fixture runtime fires the signal when
|
||||
fixture or manifest files change, so you can watch it work by editing a
|
||||
fixture file.
|
||||
|
||||
## Surface expectations (visual / accessibility baseline)
|
||||
|
||||
v0.1 deliberately declares **no required visual system and no enforced
|
||||
accessibility baseline** — surfaces are sovereign inside their iframe. Two
|
||||
conventions are stated so surfaces cohere with the shell without a rulebook:
|
||||
|
||||
- Declare `color-scheme: dark` (the shell is dark; a light-flashing iframe
|
||||
reads as broken).
|
||||
- Degraded and error states must be **visible in the UI**, not only in the
|
||||
console — when something is wrong it must look wrong.
|
||||
|
||||
A formal baseline (contrast, keyboard reachability, test convention) is a
|
||||
candidate for a later contract version, not an unstated v0.1 rule.
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/mvschwarz/openrig-studio/blob/main/contract/surface-row.schema.json",
|
||||
"title": "openrig studio — surface manifest row (contract v0.1)",
|
||||
"description": "One row in surfaces.json surfaces[]. Stability: fields marked x-stability 'stable' change only additively while the contract is 0.x; 'provisional' fields may change with a minor bump. The runtime validates warn-first: rows failing required checks are excluded from the served rail and named in GET /api/contract manifest.errors[]; unknown fields are warnings, never errors.",
|
||||
"type": "object",
|
||||
"required": ["id", "name"],
|
||||
"oneOf": [
|
||||
{ "required": ["path"], "not": { "required": ["url"] } },
|
||||
{ "required": ["url"], "not": { "required": ["path"] } }
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string", "minLength": 1,
|
||||
"description": "Unique surface id. Used by deep links (?s=<id>) and the open-surface shell message.",
|
||||
"x-stability": "stable"
|
||||
},
|
||||
"name": {
|
||||
"type": "string", "minLength": 1,
|
||||
"description": "Rail label. Short, uppercase by convention.",
|
||||
"x-stability": "stable"
|
||||
},
|
||||
"path": {
|
||||
"type": "string", "pattern": "^/",
|
||||
"description": "Same-origin path the shell loads in the stage iframe (e.g. /surfaces/mine.html). Exactly one of path or url is required.",
|
||||
"x-stability": "stable"
|
||||
},
|
||||
"url": {
|
||||
"type": "string", "pattern": "^https?://[^/?#\\s]+",
|
||||
"description": "Absolute http(s) URL with a non-empty host (e.g. https://host/path) for external surfaces. Exactly one of path or url is required — never both.",
|
||||
"x-stability": "provisional"
|
||||
},
|
||||
"glyph": { "type": "string", "description": "One-character rail glyph.", "x-stability": "stable" },
|
||||
"hint": { "type": "string", "description": "One-line tooltip shown on the rail tab.", "x-stability": "stable" },
|
||||
"category": { "type": "string", "description": "Grouping hint (create | build | grow | system by convention).", "x-stability": "provisional" },
|
||||
"popout": { "type": "boolean", "description": "Open in a new tab instead of the stage.", "x-stability": "stable" },
|
||||
"hideAgents": { "type": "boolean", "description": "Suppress the agent-sidebar toggle while this surface is active.", "x-stability": "stable" },
|
||||
"unmountOnBlur": { "type": "boolean", "description": "Remove the iframe when backgrounded instead of keeping it alive. Required for terminal-style surfaces: a hidden kept-alive client would stay attached and fight visible clients.", "x-stability": "stable" },
|
||||
"railMin": { "type": "boolean", "description": "Ask the shell to condense its rail while this surface is active.", "x-stability": "provisional" },
|
||||
"project": { "type": "string", "description": "Project-view scoping tag; surfaces without it show in every view.", "x-stability": "provisional" },
|
||||
"port": { "type": "number", "description": "Multi-process deployments only: the localhost port this surface's own server listens on. Single-runtime deployments use path.", "x-stability": "provisional" },
|
||||
"sub": { "type": "string", "description": "Multi-host deployments only: subdomain token for hosted resolution.", "x-stability": "provisional" },
|
||||
"samehost": { "type": "boolean", "description": "Resolve against the shell's own origin in hosted deployments.", "x-stability": "provisional" },
|
||||
"hosts": { "type": "array", "items": { "type": "string" }, "description": "Restrict the row to named host classes; omitted = everywhere.", "x-stability": "provisional" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
# @openrig/create-studio
|
||||
|
||||
The create-app on-ramp: scaffold a working openrig studio surface, wired to
|
||||
contract v0.1, in one command.
|
||||
|
||||
## Status of the public command
|
||||
|
||||
The intended front door is:
|
||||
|
||||
```sh
|
||||
npm create @openrig/studio my-surface
|
||||
```
|
||||
|
||||
npm resolves that to the package **`@openrig/create-studio`** and runs its bin.
|
||||
|
||||
**That command does not work yet.** This package is unpublished and its final
|
||||
name is a deferred decision, so there is nothing on the registry to resolve.
|
||||
Publishing is a metadata step — flip `private`, settle the name, publish — and
|
||||
changes nothing about the code below.
|
||||
|
||||
## Running it today
|
||||
|
||||
Directly, from the repository root:
|
||||
|
||||
```sh
|
||||
node create-studio/index.mjs my-surface
|
||||
```
|
||||
|
||||
Or through the real npm resolution path, against a local pack — same bin, same
|
||||
entrypoint, no registry:
|
||||
|
||||
```sh
|
||||
npm pack ./create-studio
|
||||
npm exec --package=./openrig-create-studio-0.1.0.tgz -- create-studio my-surface
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
```
|
||||
create-studio <name> [--dir <parent>] [--glyph <char>] [--hint <text>]
|
||||
```
|
||||
|
||||
| Option | Meaning |
|
||||
|---|---|
|
||||
| `<name>` | Project directory and surface id. Lowercase letters, digits and dashes. |
|
||||
| `--dir` | Where to create the project (default: current directory). |
|
||||
| `--glyph` | One-character rail glyph (default `◆`). |
|
||||
| `--hint` | One-line rail tooltip. |
|
||||
|
||||
## What it emits
|
||||
|
||||
```
|
||||
my-surface/
|
||||
surface.html the surface — one self-contained page, no build step
|
||||
surfaces.row.json the schema-valid manifest row that registers it
|
||||
README.md how to install it, and where the contract docs are
|
||||
```
|
||||
|
||||
The emitted surface uses **only stable-classified** contract items: the
|
||||
`/api/contract` boot check, `/api/factory/state`, the `/api/events` change
|
||||
signal (refresh-only, with the documented degraded/polling fallback), and the
|
||||
`open-agent` shell message. Nothing marked *provisional* or *runtime-internal*
|
||||
is generated.
|
||||
|
||||
## How it refuses
|
||||
|
||||
Every check runs **before** anything is written, so a rejected run leaves the
|
||||
filesystem untouched:
|
||||
|
||||
- names must be a single path segment — no traversal, no absolute paths, no `~`
|
||||
- no leading dots, no reserved names, 64 characters max
|
||||
- lowercase letters, digits and dashes only
|
||||
- an existing **non-empty** destination is refused
|
||||
|
||||
Generation stages into a sibling temp directory and lands with one atomic
|
||||
rename, so an interrupted run cannot leave a half-written project that looks
|
||||
usable.
|
||||
|
||||
`OPENRIG_STUDIO_CREATE_FAIL_AFTER=<n>` forces a failure after `n` files have
|
||||
been staged. It exists so the no-partial-output guarantee can be demonstrated
|
||||
rather than asserted; it is inert when unset.
|
||||
|
||||
## Option values are escaped per output context
|
||||
|
||||
`--hint` takes arbitrary text and `--glyph` takes any single character, so both
|
||||
can carry quotes, backslashes, newlines or markup. Each output context handles
|
||||
them differently and the generator treats them separately:
|
||||
|
||||
- **JSON** — the manifest row is built as an object and serialized. It is never
|
||||
string-substituted into a JSON literal, because that is what previously let a
|
||||
quoted hint emit a file `JSON.parse` rejects while the CLI reported success.
|
||||
- **HTML** — every interpolated value is escaped, so a hint containing markup
|
||||
renders as text rather than becoming an element.
|
||||
- **JS** — the one value crossing into a `<script>` is JSON-encoded, not
|
||||
HTML-escaped; escaping there would put a literal `"` into code.
|
||||
- **Markdown** — deliberately not escaped, because escaping inside a code span
|
||||
is wrong. Safety rests on the project-name rules, and the generator asserts
|
||||
that invariant rather than assuming it.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
npm test # from the repo root, or from create-studio/
|
||||
```
|
||||
|
||||
Zero dependencies — `node:test` only. The suite covers generation, every
|
||||
admission boundary, no-partial-output on forced failure, schema shape and
|
||||
stability class of the emitted row, the hostile-value matrix for every
|
||||
user-controlled field, and the packed-tarball bin path.
|
||||
Executable
+187
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env node
|
||||
// create-studio — scaffold an openrig studio surface project.
|
||||
//
|
||||
// The intended public front door is:
|
||||
//
|
||||
// npm create @openrig/studio <name>
|
||||
//
|
||||
// which npm resolves to the package @openrig/create-studio and runs this bin.
|
||||
// That command is NOT AVAILABLE YET: this package is unpublished (private),
|
||||
// and its final name is a deferred decision. Until it is published, run the
|
||||
// shipped entrypoint directly — see --help. Publishing is a metadata step,
|
||||
// not a rewrite: nothing below changes when it happens.
|
||||
//
|
||||
// Order of operations is the whole design: ADMIT everything first, RENDER into
|
||||
// memory, then write once. Nothing reaches the filesystem until every check has
|
||||
// passed, and generation stages into a sibling directory that is renamed into
|
||||
// place atomically. A failed run leaves no partial project.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { admitName, admitDestination } from "./lib/admission.mjs";
|
||||
import { emitProject } from "./lib/emit.mjs";
|
||||
import { escapeHtml, toJsLiteral, assertMarkdownSafe, render } from "./lib/render.mjs";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const TEMPLATES = path.join(HERE, "templates");
|
||||
|
||||
const CONTRACT_VERSION = "0.1";
|
||||
const VERSION = "0.1.0";
|
||||
|
||||
const USAGE = `create-studio — scaffold an openrig studio surface (contract v${CONTRACT_VERSION})
|
||||
|
||||
USAGE
|
||||
create-studio <name> [--dir <parent>] [--glyph <char>] [--hint <text>]
|
||||
|
||||
<name> project directory and surface id: lowercase letters, digits
|
||||
and dashes (e.g. my-surface)
|
||||
--dir <parent> where to create it (default: the current directory)
|
||||
--glyph <char> one-character rail glyph (default: ◆)
|
||||
--hint <text> one-line rail tooltip
|
||||
--help, --version
|
||||
|
||||
RUNNING IT TODAY
|
||||
node create-studio/index.mjs my-surface
|
||||
|
||||
The public form 'npm create @openrig/studio my-surface' resolves to the
|
||||
package @openrig/create-studio. That package is NOT PUBLISHED YET, so the
|
||||
command does not work today. Use the direct invocation above, or exercise the
|
||||
real npm path against a local pack:
|
||||
|
||||
npm pack ./create-studio
|
||||
npm exec --package=./openrig-create-studio-${VERSION}.tgz -- create-studio my-surface
|
||||
`;
|
||||
|
||||
function fail(message) {
|
||||
console.error(`create-studio: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = { name: undefined, dir: process.cwd(), glyph: "◆", hint: undefined };
|
||||
const rest = [];
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i];
|
||||
if (a === "--help" || a === "-h") return { help: true };
|
||||
if (a === "--version" || a === "-v") return { version: true };
|
||||
if (a === "--dir" || a === "--glyph" || a === "--hint") {
|
||||
const value = argv[i + 1];
|
||||
if (value === undefined || value.startsWith("--")) {
|
||||
fail(`${a} needs a value`);
|
||||
}
|
||||
if (a === "--dir") opts.dir = value;
|
||||
if (a === "--glyph") opts.glyph = value;
|
||||
if (a === "--hint") opts.hint = value;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (a.startsWith("-")) fail(`unknown option ${a} — see --help`);
|
||||
rest.push(a);
|
||||
}
|
||||
if (rest.length > 1) fail(`expected one project name, got ${rest.length}: ${rest.join(" ")}`);
|
||||
opts.name = rest[0];
|
||||
return opts;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
if (opts.help) { process.stdout.write(USAGE); return; }
|
||||
if (opts.version) { process.stdout.write(`${VERSION} (contract v${CONTRACT_VERSION})\n`); return; }
|
||||
|
||||
// ---- admission: everything that can say no happens before any write ------
|
||||
const named = admitName(opts.name);
|
||||
if (!named.ok) fail(named.error);
|
||||
|
||||
if (opts.glyph !== undefined && [...opts.glyph].length !== 1) {
|
||||
fail(`--glyph must be exactly one character, got ${JSON.stringify(opts.glyph)}`);
|
||||
}
|
||||
|
||||
const placed = admitDestination(named.name, opts.dir);
|
||||
if (!placed.ok) fail(placed.error);
|
||||
|
||||
// ---- render: build every file in memory ---------------------------------
|
||||
const id = named.name;
|
||||
const label = id.replace(/-/g, " ").toUpperCase();
|
||||
const hint = opts.hint ?? `${label} — starter surface`;
|
||||
|
||||
const vars = {
|
||||
ID: id,
|
||||
NAME: named.name,
|
||||
LABEL: label,
|
||||
GLYPH: opts.glyph,
|
||||
HINT: hint,
|
||||
CONTRACT_VERSION,
|
||||
};
|
||||
|
||||
const read = (file) => fs.readFileSync(path.join(TEMPLATES, file), "utf8");
|
||||
|
||||
// JSON is built STRUCTURALLY and serialized — never string-substituted into a
|
||||
// JSON literal. Templating JSON as text is what let `--hint 'say "hello"'` and
|
||||
// `--glyph '"'` emit a file that JSON.parse rejects while the CLI exited 0.
|
||||
// JSON.stringify also escapes newlines and backslashes, which is what keeps a
|
||||
// hint from breaking out of the fenced code block in the emitted README.
|
||||
const row = { id, name: label, glyph: opts.glyph, path: `/surfaces/${id}.html`, hint };
|
||||
const rowPretty = JSON.stringify(row, null, 2);
|
||||
const rowJson = rowPretty + "\n";
|
||||
// The emitted README shows the row inline so it can be pasted without opening
|
||||
// a second file. The README places the token inside a numbered-list code
|
||||
// fence indented by three spaces, and that indent only applies to the FIRST
|
||||
// line — so every subsequent line must carry it explicitly or the block comes
|
||||
// out ragged (opening brace at 3, closing brace at 2). Keep README_INDENT in
|
||||
// step with the template's indentation.
|
||||
const README_INDENT = " ";
|
||||
const rowInline = rowPretty
|
||||
.split("\n")
|
||||
.map((line, i) => (i === 0 ? line : README_INDENT + line))
|
||||
.join("\n");
|
||||
|
||||
// Markdown is not escaped by design; this asserts the invariant that makes
|
||||
// that safe. See lib/render.mjs for why escaping code spans would be wrong.
|
||||
assertMarkdownSafe({ ID: id, LABEL: label });
|
||||
|
||||
const files = {
|
||||
// HTML context: escape every interpolated value. The one JS-string context
|
||||
// (`const TARGET_CONTRACT = ...`) takes a JSON-encoded *_JS token instead —
|
||||
// HTML-escaping there would emit a literal " into executable code.
|
||||
"surface.html": render(
|
||||
read("surface.html"),
|
||||
{ ...vars, CONTRACT_VERSION_JS: toJsLiteral(CONTRACT_VERSION) },
|
||||
(value, key) => (key.endsWith("_JS") ? value : escapeHtml(value))
|
||||
),
|
||||
"surfaces.row.json": rowJson,
|
||||
"README.md": render(read("README.md"), { ...vars, ROW_INLINE: rowInline }),
|
||||
};
|
||||
|
||||
// ---- emit: stage, then one atomic rename --------------------------------
|
||||
let result;
|
||||
try {
|
||||
result = emitProject(placed.dest, files);
|
||||
} catch (error) {
|
||||
fail(`generation failed, nothing was written: ${error.message}`);
|
||||
}
|
||||
|
||||
// Show the shortest path that is still unambiguous: a relative path only
|
||||
// when the project is actually under the cwd, otherwise the absolute one.
|
||||
// (A "../../../.." chain is technically correct and useless to read.)
|
||||
const relative = path.relative(process.cwd(), result.dest);
|
||||
const rel = !relative ? "." : relative.startsWith("..") ? result.dest : relative;
|
||||
process.stdout.write(
|
||||
`\ncreated ${rel}/ (contract v${CONTRACT_VERSION})\n` +
|
||||
result.files.map((f) => ` ${f}\n`).join("") +
|
||||
// These next steps deliberately keep the surface in the developer's OWN
|
||||
// repo. The older form told them to cp into <studio>/app/surfaces/, which in
|
||||
// a real install is node_modules — wiped by npm ci and carried by any copy
|
||||
// of the tree. The scaffolder is the first artifact a new developer reads;
|
||||
// it should not teach the path the SDK exists to remove.
|
||||
`\nnext:\n` +
|
||||
` rename ${rel}/surface.html to ${id}.html and put your row in a surfaces.json beside it\n` +
|
||||
` (${rel}/surfaces.row.json is that row; ${rel}/README.md shows the shape)\n` +
|
||||
` node <studio>/app/serve-studio.mjs --surfaces ${rel} then open http://127.0.0.1:8890/\n\n` +
|
||||
`Your surface stays in YOUR repo — nothing is copied into the studio package.\n` +
|
||||
`${rel}/README.md has the details and points at the contract docs.\n`
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,92 @@
|
||||
// Admission — every check that can reject a run happens HERE, before the
|
||||
// emitter touches the filesystem. That ordering is the contract: a rejected
|
||||
// invocation writes nothing at all, so there is no partial project to clean up.
|
||||
//
|
||||
// Kept deliberately boring and dependency-free. Each rejection names what was
|
||||
// wrong AND what to do instead, per the honest-failure stance in
|
||||
// contract/contract-meta.md.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// A project name is also a directory name and a surface id, so it has to be
|
||||
// safe as all three: one path segment, no traversal, no shell/OS surprises.
|
||||
const NAME_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
||||
const MAX_NAME = 64;
|
||||
|
||||
// "." and ".." are caught by the leading-dot rule too; listed anyway so the
|
||||
// reserved set reads as a complete statement of intent rather than a leftover.
|
||||
const RESERVED = new Set([
|
||||
".", "..", "node_modules",
|
||||
"con", "prn", "aux", "nul",
|
||||
...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
|
||||
...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
|
||||
]);
|
||||
|
||||
const reject = (error) => ({ ok: false, error });
|
||||
|
||||
export function admitName(raw) {
|
||||
if (raw === undefined || raw === null || raw === "") {
|
||||
return reject('a project name is required — try: create-studio my-surface');
|
||||
}
|
||||
const name = String(raw);
|
||||
if (!name.trim()) {
|
||||
return reject('project name is blank — try: create-studio my-surface');
|
||||
}
|
||||
if (name !== name.trim()) {
|
||||
return reject(`project name has surrounding whitespace: ${JSON.stringify(name)} — remove it`);
|
||||
}
|
||||
if (name.startsWith("~")) {
|
||||
return reject(`project name must not start with "~": ${name} — pass a bare name and use --dir for the parent directory`);
|
||||
}
|
||||
if (path.isAbsolute(name)) {
|
||||
return reject(`project name must not be an absolute path: ${name} — pass a bare name and use --dir for the parent directory`);
|
||||
}
|
||||
if (name.includes("/") || name.includes("\\")) {
|
||||
return reject(`project name must be a single path segment (no "/" or "\\"): ${name} — use --dir to choose the parent directory`);
|
||||
}
|
||||
if (name.startsWith(".")) {
|
||||
return reject(`project name must not start with "." : ${name} — dotted names hide the project and ".." would escape the parent`);
|
||||
}
|
||||
if (name.length > MAX_NAME) {
|
||||
return reject(`project name is ${name.length} characters; the limit is ${MAX_NAME}`);
|
||||
}
|
||||
if (RESERVED.has(name.toLowerCase())) {
|
||||
return reject(`project name "${name}" is reserved — choose another`);
|
||||
}
|
||||
if (!NAME_RE.test(name)) {
|
||||
return reject(`project name must be lowercase letters, digits and dashes, starting and ending alphanumeric: ${name} (e.g. my-surface)`);
|
||||
}
|
||||
return { ok: true, name };
|
||||
}
|
||||
|
||||
// Destination admission assumes the name already passed admitName(), so `name`
|
||||
// is a single safe segment. The containment assertion below is defence in
|
||||
// depth: it would catch a future caller that skipped that ordering.
|
||||
export function admitDestination(name, parentRaw) {
|
||||
const parent = path.resolve(parentRaw);
|
||||
if (!fs.existsSync(parent)) {
|
||||
return reject(`destination parent does not exist: ${parent}`);
|
||||
}
|
||||
if (!fs.statSync(parent).isDirectory()) {
|
||||
return reject(`destination parent is not a directory: ${parent}`);
|
||||
}
|
||||
|
||||
const dest = path.join(parent, name);
|
||||
if (path.dirname(dest) !== parent) {
|
||||
return reject(`refusing to write outside the parent directory: ${dest} is not directly inside ${parent}`);
|
||||
}
|
||||
|
||||
if (fs.existsSync(dest)) {
|
||||
const stat = fs.statSync(dest);
|
||||
if (!stat.isDirectory()) {
|
||||
return reject(`destination already exists and is not a directory: ${dest}`);
|
||||
}
|
||||
const entries = fs.readdirSync(dest);
|
||||
if (entries.length > 0) {
|
||||
return reject(`destination already exists and is not empty: ${dest} (${entries.length} entr${entries.length === 1 ? "y" : "ies"}) — choose another name or remove it first`);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, dest };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Emit — stage every file into a sibling temp directory, then ONE atomic
|
||||
// rename into place.
|
||||
//
|
||||
// Why a sibling and not the system temp dir: rename() is only atomic within a
|
||||
// filesystem. A sibling of the destination is guaranteed to be on the same
|
||||
// filesystem; /tmp is not, and would silently degrade to a copy that can be
|
||||
// interrupted halfway.
|
||||
//
|
||||
// The guarantee this buys: the destination either does not exist, or is the
|
||||
// complete project. A crash mid-generation leaves a hidden staging directory
|
||||
// that is removed on the way out — never a half-written project a developer
|
||||
// might mistake for a working one.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export function emitProject(dest, files) {
|
||||
const parent = path.dirname(dest);
|
||||
const staging = fs.mkdtempSync(path.join(parent, ".create-studio-staging-"));
|
||||
|
||||
// Test affordance ONLY: throw after N files have been staged, to demonstrate
|
||||
// that a mid-generation failure leaves no partial project. Inert when unset.
|
||||
const failAfter = Number(process.env.OPENRIG_STUDIO_CREATE_FAIL_AFTER || 0);
|
||||
|
||||
let staged = 0;
|
||||
try {
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const target = path.join(staging, rel);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, content, "utf8");
|
||||
staged += 1;
|
||||
if (failAfter > 0 && staged >= failAfter) {
|
||||
throw new Error(
|
||||
`OPENRIG_STUDIO_CREATE_FAIL_AFTER=${failAfter} — simulated mid-generation failure after ${staged} file(s)`
|
||||
);
|
||||
}
|
||||
}
|
||||
// POSIX rename() onto an existing EMPTY directory succeeds; admission has
|
||||
// already refused a non-empty destination.
|
||||
fs.renameSync(staging, dest);
|
||||
return { dest, files: Object.keys(files) };
|
||||
} catch (error) {
|
||||
try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Rendering — substitution is ALWAYS output-context aware.
|
||||
//
|
||||
// The bug this file exists to prevent: a `--hint 'say "hello"'` or a `--glyph '"'`
|
||||
// interpolated raw into a JSON template produced an INVALID surfaces.row.json
|
||||
// while the CLI still exited 0, and hint markup landed in the surface as live
|
||||
// HTML. A generator that emits a broken artifact and reports success is the
|
||||
// worst failure shape there is — it looks like it worked.
|
||||
//
|
||||
// Rules, one per output context:
|
||||
// JSON — never templated as text. Build an object, JSON.stringify it.
|
||||
// (See the row construction in index.mjs. Escaping JSON by hand is the bug.)
|
||||
// HTML — escape &<>"' on every interpolated value.
|
||||
// JS — inside <script>, values are JSON-encoded, not HTML-escaped;
|
||||
// HTML-escaping there would emit a literal " into code.
|
||||
// Markdown — see assertMarkdownSafe below.
|
||||
|
||||
export const escapeHtml = (value) =>
|
||||
String(value ?? "").replace(/[&<>"']/g, (c) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
})[c]);
|
||||
|
||||
// A value destined for a JS string literal inside <script>. Returns the quotes
|
||||
// too, so the template writes `= {{TOKEN_JS}};` with no quotes of its own.
|
||||
export const toJsLiteral = (value) => JSON.stringify(String(value ?? ""));
|
||||
|
||||
// Markdown is deliberately NOT escaped, and that is a decision rather than an
|
||||
// oversight. The values reaching the emitted README (id, and the label derived
|
||||
// from it) land inside headings, inline code and fenced blocks. Backslash-
|
||||
// escaping inside a code span is WRONG — markdown does not interpret specials
|
||||
// there, so the escape itself would show up as literal backslashes in the
|
||||
// output the developer reads.
|
||||
//
|
||||
// Those values are safe because admission constrains the project name to
|
||||
// ^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$ — no markdown-active character can survive
|
||||
// it. That makes markdown safety DEPENDENT on a rule enforced in another file,
|
||||
// which is exactly the kind of invisible coupling that rots. So assert it here:
|
||||
// if admission is ever loosened, generation fails loudly instead of quietly
|
||||
// emitting malformed markdown.
|
||||
const MARKDOWN_ACTIVE = /[\\`*_{}\[\]()#+!|<>]/;
|
||||
|
||||
export function assertMarkdownSafe(values) {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (MARKDOWN_ACTIVE.test(String(value ?? ""))) {
|
||||
throw new Error(
|
||||
`refusing to emit: ${key} contains a markdown-active character (${JSON.stringify(value)}). ` +
|
||||
`Values reaching the emitted README must be markdown-inert; admission should have ` +
|
||||
`rejected this earlier. If the name rules were loosened, the README template needs ` +
|
||||
`context-aware escaping before that change can ship.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `escape` receives (value, key) so a caller can vary handling per token —
|
||||
// e.g. leave an already-JSON-encoded *_JS token alone while HTML-escaping the rest.
|
||||
export function render(template, vars, escape = (value) => value) {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (whole, key) =>
|
||||
key in vars ? escape(vars[key], key) : whole
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@openrig/create-studio",
|
||||
"version": "0.1.0",
|
||||
"description": "Scaffold a new openrig studio surface project wired to contract v0.1.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"create-studio": "./index.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test 'test/*.test.mjs'"
|
||||
},
|
||||
"files": [
|
||||
"index.mjs",
|
||||
"lib",
|
||||
"templates",
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
# {{LABEL}}
|
||||
|
||||
An openrig studio **surface**: a thin browser page that runs inside the studio
|
||||
shell and talks to the studio runtime over a versioned contract.
|
||||
|
||||
Built against **contract v{{CONTRACT_VERSION}}**.
|
||||
|
||||
> Generated by `create-studio`. The intended public front door,
|
||||
> `npm create @openrig/studio <name>`, is **not available yet** — that package
|
||||
> is unpublished and its final name is still an open decision. This project was
|
||||
> produced by running the scaffolder directly from the SDK repository. Nothing
|
||||
> here depends on that command existing.
|
||||
|
||||
## Files
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `surface.html` | The surface itself. One self-contained page — no build step, no dependencies. |
|
||||
| `surfaces.row.json` | The manifest row that registers this surface with a studio. |
|
||||
|
||||
## Register it
|
||||
|
||||
A surface runs inside a studio; it is not a server of its own. **This project is
|
||||
yours** — it stays in your repository and the studio reads it from there. You do
|
||||
not copy anything into the SDK.
|
||||
|
||||
1. Keep this directory (or move it) wherever you like in your own repo, and put
|
||||
your `surfaces.json` beside the page:
|
||||
|
||||
```
|
||||
surfaces/
|
||||
surfaces.json the row below, in a { "surfaces": [ … ] } array
|
||||
{{ID}}.html this project's surface.html, renamed
|
||||
```
|
||||
|
||||
```json
|
||||
{{ROW_INLINE}}
|
||||
```
|
||||
|
||||
2. Point the runtime at that directory:
|
||||
|
||||
```sh
|
||||
node <studio>/app/serve-studio.mjs --surfaces ./surfaces
|
||||
# or
|
||||
OPENRIG_STUDIO_SURFACES=./surfaces node <studio>/app/serve-studio.mjs
|
||||
```
|
||||
|
||||
Then reload the shell. `{{LABEL}}` appears on the rail.
|
||||
|
||||
**Do not copy your surface into the studio's `app/surfaces/`.** If you installed
|
||||
the SDK as a dependency that directory is inside `node_modules` — gitignored,
|
||||
destroyed by `npm ci`, and carried by any copy of the tree, so a copied
|
||||
deployment can serve a healthy manifest with the wrong surfaces. Surfaces you
|
||||
own belong in your repository. See `contract/manifest.md` →
|
||||
*Registering surfaces as a consumer*.
|
||||
|
||||
The shell reads the manifest **once at load**, so an already-open tab will not
|
||||
show a newly added row until you reload it. The runtime re-validates the
|
||||
manifest live — check `GET /api/contract` to see whether your row was accepted.
|
||||
|
||||
## Run it
|
||||
|
||||
Start the studio runtime (from the studio directory):
|
||||
|
||||
```sh
|
||||
node app/serve-studio.mjs
|
||||
```
|
||||
|
||||
Open <http://127.0.0.1:8890/> and pick **{{LABEL}}** from the rail.
|
||||
|
||||
If your row does not appear, the reason is spelled out in
|
||||
`GET /api/contract` → `manifest.errors[]`. Validation is warn-first: a bad row
|
||||
is excluded and named, and the runtime keeps serving.
|
||||
|
||||
## What this skeleton already does
|
||||
|
||||
- Checks `GET /api/contract` at boot and refuses to run against a runtime that
|
||||
cannot serve it — visibly, not silently.
|
||||
- Reads `GET /api/factory/state` and renders it.
|
||||
- Subscribes to `GET /api/events` and re-fetches on any signal, with a
|
||||
15-second polling fallback when the stream drops or reports `degraded`.
|
||||
- Opens the agent sidebar via the `open-agent` shell message.
|
||||
|
||||
It uses **only stable-classified** contract items. Nothing marked
|
||||
*provisional* or *runtime-internal* appears in the generated code.
|
||||
|
||||
## Extend it
|
||||
|
||||
Read these four documents — they are the whole contract, and they are enough:
|
||||
|
||||
| Document | What it defines |
|
||||
|---|---|
|
||||
| `contract/contract-meta.md` | Version, stability classes, compatibility rules. **Start here.** |
|
||||
| `contract/manifest.md` | How a surface registers |
|
||||
| `contract/shell-protocol.md` | Lifecycle, URL rules, the postMessage API |
|
||||
| `contract/runtime-api.md` | The HTTP verbs, their shapes and failure semantics |
|
||||
|
||||
Plus `contract/surface-row.schema.json` for the manifest row fields.
|
||||
|
||||
In `surface.html`, replace the body of `render()` with whatever your surface is
|
||||
for. The boot check, the change-signal loop and the failure handling around it
|
||||
are already correct — a normal surface should not need to change them.
|
||||
|
||||
A live runtime self-describes at `GET /api/contract`; that is the fastest way
|
||||
to see which capabilities you actually have.
|
||||
@@ -0,0 +1,271 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{LABEL}}</title>
|
||||
<!--
|
||||
{{LABEL}} — an openrig studio surface skeleton.
|
||||
Targets contract v{{CONTRACT_VERSION}}. Read contract/contract-meta.md first.
|
||||
|
||||
Everything below uses ONLY stable-classified contract items:
|
||||
GET /api/contract capability + version check at boot
|
||||
GET /api/factory/state the read-only observe verb this skeleton renders
|
||||
GET /api/events change signal — refresh-only, payload never parsed
|
||||
postMessage open-agent the surface -> shell channel
|
||||
|
||||
It deliberately uses nothing marked provisional or runtime-internal.
|
||||
Extend it by adding your own render; the loop below already keeps you fresh.
|
||||
-->
|
||||
<style>
|
||||
:root {
|
||||
/* The shell is dark. Declaring this stops a light flash on load, which
|
||||
reads as a broken surface. (contract/shell-protocol.md) */
|
||||
color-scheme: dark;
|
||||
--bg: #0b0e13;
|
||||
--panel: #121722;
|
||||
--line: #252c3a;
|
||||
--text: #f3f5f8;
|
||||
--muted: #929cad;
|
||||
--accent: #47d18c;
|
||||
--warn: #ffb44a;
|
||||
--info: #6d9eff;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
main { width: min(880px, 100%); margin: 0 auto; padding: 26px; }
|
||||
|
||||
header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 16px; flex-wrap: wrap; margin-bottom: 22px;
|
||||
}
|
||||
.eyebrow {
|
||||
color: var(--accent); font-size: 11px;
|
||||
letter-spacing: .16em; text-transform: uppercase;
|
||||
}
|
||||
h1 {
|
||||
margin: 4px 0 0;
|
||||
font: 600 clamp(24px, 4vw, 34px)/1.1 system-ui, sans-serif;
|
||||
letter-spacing: -.03em;
|
||||
}
|
||||
.status { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--warn); }
|
||||
.status.live .dot { background: var(--accent); }
|
||||
.status.polling .dot { background: var(--info); }
|
||||
|
||||
.cards { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
|
||||
.card {
|
||||
border: 1px solid var(--line); border-radius: 12px;
|
||||
background: var(--panel); padding: 16px;
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0 0 10px; font-size: 11px; font-weight: 600; color: var(--muted);
|
||||
letter-spacing: .14em; text-transform: uppercase;
|
||||
}
|
||||
.big { font: 650 26px/1 system-ui, sans-serif; }
|
||||
.row { display: flex; justify-content: space-between; gap: 12px; padding: 5px 0; }
|
||||
.row + .row { border-top: 1px solid var(--line); }
|
||||
.key { color: var(--muted); }
|
||||
.val { overflow-wrap: anywhere; text-align: right; }
|
||||
|
||||
.notice {
|
||||
border: 1px solid var(--warn); border-radius: 12px;
|
||||
padding: 15px; color: var(--warn); background: #ffb44a10;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit; color: var(--text); cursor: pointer;
|
||||
background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 9px; padding: 9px 13px;
|
||||
}
|
||||
button:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
footer { margin-top: 20px; color: var(--muted); font-size: 11px; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
main { padding: 16px; }
|
||||
header { flex-direction: column; align-items: flex-start; gap: 10px; }
|
||||
.val { text-align: left; }
|
||||
.row { flex-direction: column; gap: 2px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<div>
|
||||
<div class="eyebrow">{{HINT}}</div>
|
||||
<h1>{{LABEL}}</h1>
|
||||
</div>
|
||||
<div class="status" id="status"><span class="dot"></span><span id="statusText">connecting</span></div>
|
||||
</header>
|
||||
|
||||
<div id="content">
|
||||
<div class="notice">Reading the runtime contract…</div>
|
||||
</div>
|
||||
|
||||
<p style="margin-top:18px">
|
||||
<button id="agents" type="button">Open agent sidebar</button>
|
||||
</p>
|
||||
|
||||
<footer id="footer">Targets contract v{{CONTRACT_VERSION}}.</footer>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// ---- what this surface needs from the runtime ---------------------------
|
||||
// Every namespace listed here is checked at boot. A runtime that cannot
|
||||
// serve one of them gets a visible failure, not a silent empty render.
|
||||
const NEEDED = ["observe.factory-state", "stream.events"];
|
||||
const TARGET_CONTRACT = {{CONTRACT_VERSION_JS}};
|
||||
const POLL_MS = 15000; // contract/shell-protocol.md: 10-30s, 15s recommended
|
||||
|
||||
const content = document.querySelector("#content");
|
||||
const statusEl = document.querySelector("#status");
|
||||
const statusText = document.querySelector("#statusText");
|
||||
const footer = document.querySelector("#footer");
|
||||
|
||||
let events = null;
|
||||
let pollTimer = null;
|
||||
let retryTimer = null;
|
||||
|
||||
const esc = (value) => String(value ?? "").replace(/[&<>'"]/g, (c) => ({
|
||||
"&": "&", "<": "<", ">": ">", "'": "'", '"': """
|
||||
})[c]);
|
||||
|
||||
function setStatus(mode, label) {
|
||||
statusEl.className = "status " + mode;
|
||||
statusText.textContent = label;
|
||||
}
|
||||
|
||||
function showNotice(message) {
|
||||
content.innerHTML = '<div class="notice">' + esc(message) + "</div>";
|
||||
}
|
||||
|
||||
// ---- render ------------------------------------------------------------
|
||||
// TODO: this is your starting point. Replace the body of render() with
|
||||
// whatever your surface is actually for. The boot check, the change-signal
|
||||
// loop and the failure handling around it are already correct — you should
|
||||
// not need to touch them to build a normal surface.
|
||||
function render(state) {
|
||||
if (!state.ok) {
|
||||
// A degraded read is the runtime being honest: healthy server, unhealthy
|
||||
// data. Show it. Do not retry-loop. (contract/runtime-api.md)
|
||||
showNotice("Data degraded: " + (state.degraded || "unknown reason"));
|
||||
return;
|
||||
}
|
||||
|
||||
const seats = Array.isArray(state.seats) ? state.seats : [];
|
||||
const queue = Array.isArray(state.queue) ? state.queue : [];
|
||||
const working = seats.filter((s) => s.state === "working").length;
|
||||
|
||||
const seatRows = seats.slice(0, 6).map((seat) => (
|
||||
'<div class="row"><span class="key">' + esc(seat.seat) + "</span>" +
|
||||
'<span class="val">' + esc(seat.state) + "</span></div>"
|
||||
)).join("") || '<div class="row"><span class="key">no seats reported</span></div>';
|
||||
|
||||
content.innerHTML =
|
||||
'<div class="cards">' +
|
||||
'<div class="card"><h2>Rig</h2>' +
|
||||
'<div class="big">' + esc(state.rig) + "</div>" +
|
||||
'<div class="row"><span class="key">seats</span><span class="val">' + seats.length + "</span></div>" +
|
||||
'<div class="row"><span class="key">working</span><span class="val">' + working + "</span></div>" +
|
||||
'<div class="row"><span class="key">queue</span><span class="val">' + queue.length + "</span></div>" +
|
||||
"</div>" +
|
||||
'<div class="card"><h2>Seats</h2>' + seatRows + "</div>" +
|
||||
"</div>";
|
||||
|
||||
footer.textContent =
|
||||
"Targets contract v" + TARGET_CONTRACT +
|
||||
" · snapshot " + new Date(state.generatedAt).toLocaleTimeString();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const response = await fetch("/api/factory/state", { cache: "no-store" });
|
||||
render(await response.json());
|
||||
} catch (error) {
|
||||
showNotice("Unable to read runtime state: " + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the change-signal loop --------------------------------------------
|
||||
// A message means "something changed, re-fetch". The payload is NOT data
|
||||
// and must never be parsed. (contract/runtime-api.md, stream section)
|
||||
function startPolling() {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = setInterval(refresh, POLL_MS);
|
||||
setStatus("polling", "polling fallback");
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
if (events) { try { events.close(); } catch (e) { /* already closed */ } }
|
||||
startPolling();
|
||||
// Re-fetch NOW, not at the next poll tick. Without this the surface keeps
|
||||
// showing the last good render for up to POLL_MS after the runtime goes
|
||||
// away — stale data that still looks live. When something is wrong it has
|
||||
// to LOOK wrong immediately. (contract/shell-protocol.md)
|
||||
refresh();
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = setTimeout(connect, POLL_MS); // keep trying the stream
|
||||
}
|
||||
|
||||
function connect() {
|
||||
clearTimeout(retryTimer);
|
||||
if (events) { try { events.close(); } catch (e) { /* already closed */ } }
|
||||
events = new EventSource("/api/events");
|
||||
events.onopen = () => {
|
||||
clearInterval(pollTimer);
|
||||
setStatus("live", "live signal");
|
||||
refresh(); // no replay guarantee: re-fetch once on every (re)connect
|
||||
};
|
||||
events.onmessage = () => refresh();
|
||||
events.addEventListener("degraded", fallback); // signal source unhealthy
|
||||
events.onerror = fallback;
|
||||
}
|
||||
|
||||
// ---- boot --------------------------------------------------------------
|
||||
async function boot() {
|
||||
try {
|
||||
const response = await fetch("/api/contract", { cache: "no-store" });
|
||||
const contract = await response.json();
|
||||
|
||||
// The compatibility rule from contract/contract-meta.md: capabilities
|
||||
// present + same MAJOR version. An exact-match check would refuse
|
||||
// runtimes that are contractually guaranteed to work.
|
||||
const missing = NEEDED.filter((n) => !(contract.capabilities || []).includes(n));
|
||||
const major = String(contract.contractVersion ?? "").split(".")[0];
|
||||
const wantMajor = TARGET_CONTRACT.split(".")[0];
|
||||
|
||||
if (major !== wantMajor || missing.length) {
|
||||
throw new Error(
|
||||
"runtime contract v" + contract.contractVersion +
|
||||
" cannot serve this surface (built for v" + TARGET_CONTRACT + ")" +
|
||||
(missing.length ? "; missing capability: " + missing.join(", ") : "")
|
||||
);
|
||||
}
|
||||
|
||||
await refresh();
|
||||
connect();
|
||||
} catch (error) {
|
||||
// Fail visibly, then degrade to polling rather than showing nothing.
|
||||
showNotice("Cannot start: " + error.message);
|
||||
startPolling();
|
||||
}
|
||||
}
|
||||
|
||||
// The only surface -> shell channel. Message set is closed for v0.1.
|
||||
document.querySelector("#agents").addEventListener("click", () => {
|
||||
parent.postMessage({ t: "open-agent" }, "*");
|
||||
});
|
||||
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,278 @@
|
||||
// Regression suite for create-studio. Zero dependencies — node:test only.
|
||||
//
|
||||
// node --test create-studio/test/
|
||||
//
|
||||
// Two things the
|
||||
// original ad-hoc smoke testing missed, and which this suite exists to keep
|
||||
// missing-proof:
|
||||
//
|
||||
// 1. Option VALUES were never exercised. Admission was covered thoroughly, so
|
||||
// the harness looked strong, but `--hint 'say "hello"'` emitted invalid
|
||||
// JSON while the CLI exited 0. Every user-controlled field is now run
|
||||
// through a hostile-value matrix.
|
||||
// 2. Nothing ran twice. A manual pass is not regression coverage: it
|
||||
// does not run tomorrow.
|
||||
//
|
||||
// Assertion discipline: assert on CONTENT, never on exit status alone. A test
|
||||
// that only checks a non-zero exit passes just as happily on a syntax error in
|
||||
// the CLI as on a correct rejection. Rejection tests therefore assert the
|
||||
// expected REASON, and `positive control` proves the suite can actually fail.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PKG = path.resolve(HERE, "..");
|
||||
const CLI = path.join(PKG, "index.mjs");
|
||||
const SCHEMA = JSON.parse(
|
||||
fs.readFileSync(path.resolve(PKG, "..", "contract", "surface-row.schema.json"), "utf8")
|
||||
);
|
||||
|
||||
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "create-studio-test-"));
|
||||
|
||||
function run(args, { cwd, env } = {}) {
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [CLI, ...args], {
|
||||
cwd: cwd ?? os.tmpdir(),
|
||||
env: { ...process.env, ...env },
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
return { code: 0, stdout, stderr: "" };
|
||||
} catch (e) {
|
||||
return { code: e.status ?? 1, stdout: e.stdout ?? "", stderr: e.stderr ?? "" };
|
||||
}
|
||||
}
|
||||
|
||||
const emitted = (dir, name) => ({
|
||||
row: JSON.parse(fs.readFileSync(path.join(dir, name, "surfaces.row.json"), "utf8")),
|
||||
html: fs.readFileSync(path.join(dir, name, "surface.html"), "utf8"),
|
||||
readme: fs.readFileSync(path.join(dir, name, "README.md"), "utf8"),
|
||||
});
|
||||
|
||||
// Entries left behind under a parent, ignoring nothing — staging directories are
|
||||
// hidden, so a naive readdir that skipped dotfiles would hide exactly the
|
||||
// failure this checks for.
|
||||
const entries = (dir) => fs.readdirSync(dir);
|
||||
|
||||
// ---------------------------------------------------------------- generation
|
||||
|
||||
test("emits exactly the three project files and reports success", () => {
|
||||
const dir = tmp();
|
||||
const r = run(["my-surface", "--dir", dir]);
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
assert.deepEqual(fs.readdirSync(path.join(dir, "my-surface")).sort(),
|
||||
["README.md", "surface.html", "surfaces.row.json"]);
|
||||
assert.match(r.stdout, /created/);
|
||||
});
|
||||
|
||||
test("emitted row is schema-shaped: required present, exactly one target, stable fields only", () => {
|
||||
const dir = tmp();
|
||||
assert.equal(run(["my-surface", "--dir", dir]).code, 0);
|
||||
const { row } = emitted(dir, "my-surface");
|
||||
|
||||
for (const key of SCHEMA.required) assert.ok(key in row, `missing required field ${key}`);
|
||||
assert.ok(("path" in row) !== ("url" in row), "must carry exactly one of path|url");
|
||||
|
||||
for (const key of Object.keys(row)) {
|
||||
const prop = SCHEMA.properties[key];
|
||||
assert.ok(prop, `emitted unknown field ${key}`);
|
||||
assert.equal(prop["x-stability"], "stable",
|
||||
`emitted ${key}, which is ${prop["x-stability"]} — the scaffolder emits stable fields only`);
|
||||
}
|
||||
assert.match(row.path, /^\//);
|
||||
});
|
||||
|
||||
test("emitted surface targets the contract version and only stable endpoints", () => {
|
||||
const dir = tmp();
|
||||
assert.equal(run(["my-surface", "--dir", dir]).code, 0);
|
||||
const { html } = emitted(dir, "my-surface");
|
||||
|
||||
assert.match(html, /const TARGET_CONTRACT = "0\.1"/);
|
||||
const endpoints = [...html.matchAll(/\/api\/[a-z/]+/g)].map((m) => m[0]);
|
||||
for (const e of new Set(endpoints)) {
|
||||
assert.ok(["/api/contract", "/api/factory/state", "/api/events"].includes(e),
|
||||
`emitted a non-stable endpoint: ${e}`);
|
||||
}
|
||||
// runtime-internal surfaces named in contract/runtime-api.md must never appear
|
||||
assert.doesNotMatch(html, /files\/(write|goto|roots)|sidebar-arrangement|oauth|credential/i);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ escaping
|
||||
// The regression that produced this suite. Every user-controlled field crossed
|
||||
// with every metacharacter class that has a different meaning in JSON, HTML or
|
||||
// Markdown.
|
||||
|
||||
const HOSTILE = [
|
||||
['double quote', 'say "hello" now'],
|
||||
['backslash', "back\\slash and \\\" mix"],
|
||||
['newline', "line one\nline two"],
|
||||
['html metachars', '<strong>OWNED</strong> & <img src=x onerror=alert(1)>'],
|
||||
['markdown fence', "```json\nnot really\n```"],
|
||||
['json braces', '{"injected": true}'],
|
||||
['unicode + quote', '◆ "◆" ◆'],
|
||||
];
|
||||
|
||||
for (const [label, value] of HOSTILE) {
|
||||
test(`hint survives ${label} in every output context`, () => {
|
||||
const dir = tmp();
|
||||
const r = run(["my-surface", "--dir", dir, "--hint", value]);
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
|
||||
// JSON context: must parse, and must round-trip the value EXACTLY.
|
||||
const { row, html, readme } = emitted(dir, "my-surface");
|
||||
assert.equal(row.hint, value, "hint did not round-trip through the emitted JSON");
|
||||
|
||||
// HTML context: no raw markup may survive into the document.
|
||||
assert.doesNotMatch(html, /<strong>OWNED<\/strong>/);
|
||||
assert.doesNotMatch(html, /<img src=x/);
|
||||
|
||||
// Markdown context: the fenced blocks in the README must still be balanced.
|
||||
// Fences here are list-indented, so anchor on optional leading whitespace —
|
||||
// matching only column-0 fences would silently skip them.
|
||||
const fences = (readme.match(/^\s*```/gm) || []).length;
|
||||
assert.equal(fences % 2, 0, "emitted README has an unbalanced code fence");
|
||||
|
||||
// The README's inline row must still be parseable JSON, and must be indented
|
||||
// consistently — a ragged block is what a developer copy-pastes out.
|
||||
const block = readme.match(/```json\n([\s\S]*?)\n\s*```/);
|
||||
assert.ok(block, "emitted README lost its json block");
|
||||
const lines = block[1].split("\n");
|
||||
const indent = (l) => l.match(/^ */)[0].length;
|
||||
assert.equal(indent(lines[0]), indent(lines[lines.length - 1]),
|
||||
"json block is ragged: opening and closing braces are at different indents");
|
||||
JSON.parse(block[1]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [label, value] of [['double quote', '"'], ['backslash', "\\"], ['newline', "\n"], ['angle bracket', "<"]]) {
|
||||
test(`glyph survives ${label}`, () => {
|
||||
const dir = tmp();
|
||||
const r = run(["my-surface", "--dir", dir, "--glyph", value]);
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
const { row } = emitted(dir, "my-surface");
|
||||
assert.equal(row.glyph, value, "glyph did not round-trip through the emitted JSON");
|
||||
});
|
||||
}
|
||||
|
||||
test("negative control: a plain hint also passes (the matrix is not vacuously green)", () => {
|
||||
const dir = tmp();
|
||||
assert.equal(run(["my-surface", "--dir", dir, "--hint", "plain text"]).code, 0);
|
||||
assert.equal(emitted(dir, "my-surface").row.hint, "plain text");
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------- admission
|
||||
|
||||
const REJECTIONS = [
|
||||
["traversal", ["../evil"], /single path segment/],
|
||||
["absolute path", ["/tmp/evil"], /must not be an absolute/],
|
||||
["nested segment", ["a/b"], /single path segment/],
|
||||
["uppercase and punctuation", ["My Surface!"], /lowercase letters/],
|
||||
["empty name", [""], /project name is required/],
|
||||
["missing name", [], /project name is required/],
|
||||
["parent traversal name", [".."], /must not start with/],
|
||||
["leading dot", [".hidden"], /must not start with/],
|
||||
["reserved device name", ["con"], /is reserved/],
|
||||
["tilde", ["~evil"], /must not start with/],
|
||||
["trailing dash", ["bad-"], /lowercase letters/],
|
||||
["over-long name", ["a".repeat(65)], /characters; the limit/],
|
||||
];
|
||||
|
||||
for (const [label, args, expected] of REJECTIONS) {
|
||||
test(`rejects ${label} and writes nothing`, () => {
|
||||
const dir = tmp();
|
||||
const r = run([...args, "--dir", dir]);
|
||||
assert.notEqual(r.code, 0, `expected rejection for ${label}`);
|
||||
assert.match(r.stderr, expected, `wrong reason for ${label}: ${r.stderr}`);
|
||||
assert.deepEqual(entries(dir), [], "a rejected run must leave the filesystem untouched");
|
||||
});
|
||||
}
|
||||
|
||||
test("rejects a multi-character glyph", () => {
|
||||
const dir = tmp();
|
||||
const r = run(["ok-name", "--dir", dir, "--glyph", "ab"]);
|
||||
assert.notEqual(r.code, 0);
|
||||
assert.match(r.stderr, /exactly one character/);
|
||||
assert.deepEqual(entries(dir), []);
|
||||
});
|
||||
|
||||
test("rejects an option missing its value, and an unknown option", () => {
|
||||
const dir = tmp();
|
||||
assert.match(run(["ok-name", "--dir"]).stderr, /needs a value/);
|
||||
const r = run(["ok-name", "--bogus", "--dir", dir]);
|
||||
assert.notEqual(r.code, 0);
|
||||
assert.match(r.stderr, /unknown option/);
|
||||
assert.deepEqual(entries(dir), []);
|
||||
});
|
||||
|
||||
test("refuses a non-empty destination and leaves its contents intact", () => {
|
||||
const dir = tmp();
|
||||
fs.mkdirSync(path.join(dir, "occupied"));
|
||||
fs.writeFileSync(path.join(dir, "occupied", "keep.txt"), "existing work");
|
||||
const r = run(["occupied", "--dir", dir]);
|
||||
assert.notEqual(r.code, 0);
|
||||
assert.match(r.stderr, /is not empty/);
|
||||
assert.equal(fs.readFileSync(path.join(dir, "occupied", "keep.txt"), "utf8"), "existing work");
|
||||
assert.deepEqual(fs.readdirSync(path.join(dir, "occupied")), ["keep.txt"]);
|
||||
});
|
||||
|
||||
test("accepts an existing EMPTY destination", () => {
|
||||
const dir = tmp();
|
||||
fs.mkdirSync(path.join(dir, "empty-dest"));
|
||||
const r = run(["empty-dest", "--dir", dir]);
|
||||
assert.equal(r.code, 0, r.stderr);
|
||||
assert.ok(fs.existsSync(path.join(dir, "empty-dest", "surface.html")));
|
||||
});
|
||||
|
||||
test("refuses a destination whose parent does not exist", () => {
|
||||
const r = run(["my-surface", "--dir", path.join(tmp(), "nope")]);
|
||||
assert.notEqual(r.code, 0);
|
||||
assert.match(r.stderr, /parent does not exist/);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ atomicity
|
||||
|
||||
test("a mid-generation failure leaves no project and no staging residue", () => {
|
||||
const dir = tmp();
|
||||
const r = run(["halfway", "--dir", dir], { env: { OPENRIG_STUDIO_CREATE_FAIL_AFTER: "2" } });
|
||||
assert.notEqual(r.code, 0);
|
||||
assert.match(r.stderr, /nothing was written/);
|
||||
assert.deepEqual(entries(dir), [],
|
||||
"destination or hidden staging directory survived a failed generation");
|
||||
});
|
||||
|
||||
test("failure injection is inert when unset (the atomicity test is not self-fulfilling)", () => {
|
||||
const dir = tmp();
|
||||
assert.equal(run(["halfway", "--dir", dir]).code, 0);
|
||||
assert.ok(fs.existsSync(path.join(dir, "halfway", "surface.html")));
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- packaged bin
|
||||
|
||||
test("the packed tarball exposes a working bin through npm's own resolution", { timeout: 180_000 }, () => {
|
||||
const dir = tmp();
|
||||
const packed = execSync(`npm pack ${JSON.stringify(PKG)} --pack-destination ${JSON.stringify(dir)}`,
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim().split("\n").pop();
|
||||
const tarball = path.join(dir, packed);
|
||||
assert.ok(fs.existsSync(tarball), "npm pack produced no tarball");
|
||||
|
||||
const out = path.join(dir, "out");
|
||||
fs.mkdirSync(out);
|
||||
execSync(`npm exec --yes --package=${JSON.stringify(tarball)} -- create-studio packed-check`,
|
||||
{ cwd: out, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
||||
|
||||
assert.deepEqual(fs.readdirSync(path.join(out, "packed-check")).sort(),
|
||||
["README.md", "surface.html", "surfaces.row.json"]);
|
||||
JSON.parse(fs.readFileSync(path.join(out, "packed-check", "surfaces.row.json"), "utf8"));
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ suite sanity
|
||||
|
||||
test("positive control: this suite is capable of failing", () => {
|
||||
assert.throws(() => assert.equal(1, 2));
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"rig": "fixture-rig",
|
||||
"seats": [
|
||||
{ "seat": "dev-driver@fixture-rig", "pod": "dev", "member": "driver", "state": "working",
|
||||
"ageMinutes": 2, "hasWork": true, "pendingWork": 1, "lifecycle": "running" },
|
||||
{ "seat": "dev-qa@fixture-rig", "pod": "dev", "member": "qa", "state": "idle",
|
||||
"ageMinutes": 14, "hasWork": false, "pendingWork": 0, "lifecycle": "running" },
|
||||
{ "seat": "orch-lead@fixture-rig", "pod": "orch", "member": "lead", "state": "blocked",
|
||||
"ageMinutes": 31, "hasWork": true, "pendingWork": 3, "lifecycle": "running" },
|
||||
{ "seat": "review-r1@fixture-rig", "pod": "review", "member": "r1", "state": "down",
|
||||
"ageMinutes": null, "hasWork": false, "pendingWork": 0, "lifecycle": "stopped" }
|
||||
],
|
||||
"queue": [
|
||||
{ "id": "qitem-fixture-0001", "state": "in-progress", "destination": "dev-driver@fixture-rig",
|
||||
"source": "orch-lead@fixture-rig", "tags": ["fixture", "demo"], "updated": "2026-07-31T00:00:00.000Z",
|
||||
"title": "Implement the widget endpoint" },
|
||||
{ "id": "qitem-fixture-0002", "state": "pending", "destination": "dev-qa@fixture-rig",
|
||||
"source": "dev-driver@fixture-rig", "tags": ["fixture", "review"], "updated": "2026-07-31T00:05:00.000Z",
|
||||
"title": "Review the widget endpoint change" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# files-root
|
||||
|
||||
This directory is the pinned root the runtime's read-only files verbs serve.
|
||||
Everything under it is reachable through `/api/files/*`; nothing outside it is.
|
||||
|
||||
It is fixture content: safe demo files a surface can list, search, and read.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Welcome note
|
||||
|
||||
A small markdown file that lives inside the pinned files root, so surfaces
|
||||
have something real to read through `/api/files/read`.
|
||||
|
||||
- It renders as markdown if your surface chooses to.
|
||||
- Its mtime changes if you edit it, which fires the change-signal stream.
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@openrig/studio",
|
||||
"version": "0.3.0",
|
||||
"description": "SDK for building agent-driven mini-apps: thin surfaces paired with agents, running in a shared shell.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node app/serve-studio.mjs",
|
||||
"create": "node create-studio/index.mjs",
|
||||
"test": "node --test 'create-studio/test/*.test.mjs' 'test/*.test.mjs'"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
name: building-studio-apps
|
||||
description: Use when building, packaging, or registering an app, surface, or tab for the OpenRig Studio SDK (@openrig/studio) — including deciding whether an app needs its own server, why a surface loads but its panes stay empty, why a tab 404s or shows another project's data, or why an app that works on the machine that built it fails on a fresh install.
|
||||
---
|
||||
|
||||
# Building Studio Apps
|
||||
|
||||
## What the SDK actually is
|
||||
|
||||
`@openrig/studio` is an **app shell and a contract** — a rail of tabs, a stage, an agent
|
||||
sidebar, and a protocol for how a surface talks to them. It is **not a framework you import.**
|
||||
Nothing in your app needs to `require` it.
|
||||
|
||||
That single fact determines everything else: your app can be plain HTML, Next.js, Go, Rust, or
|
||||
a container. If it answers on a URL and conforms to the contract, it can be a tab.
|
||||
|
||||
**The shell is a directory + a frame + an agent bridge. You do not install an app INTO it.
|
||||
You register a ROW.**
|
||||
|
||||
## The one decision that shapes everything
|
||||
|
||||
**Ultralight vs heavy is not about size. It is about WHO SERVES THE BYTES.**
|
||||
|
||||
| | ultralight | heavy |
|
||||
|---|---|---|
|
||||
| what it is | HTML + a script, riding verbs the shell already serves | brings its own server |
|
||||
| registered as | `path: /surfaces/foo.html` | `url: https://…` |
|
||||
| needs the SDK to run? | **yes — the shell IS its runtime** | **no — only to appear as a tab** |
|
||||
|
||||
**This inverts the intuitive reading and you will get it backwards.** The *small* app is the one
|
||||
that genuinely depends on the SDK. The *big* one runs fine without it and only needs a row.
|
||||
|
||||
Consequence worth internalising: a heavy app just runs your service, adds a row, and gets the agent panel beside it — zero integration code.
|
||||
|
||||
## The three steps
|
||||
|
||||
1. **Install it** however that kind of software normally installs — git clone, npm, docker.
|
||||
The SDK has no opinion and no installer.
|
||||
2. **Register a row** so it appears on the rail. → `app-manifest.md`
|
||||
3. **Make it work**, which is a different claim from step 2 and is where the time goes.
|
||||
→ `providers-and-roots.md`, `failure-modes.md`
|
||||
|
||||
## Read next
|
||||
|
||||
| file | read it when |
|
||||
|---|---|
|
||||
| `app-manifest.md` | writing `app.json`; declaring a provider, roots, or vendored assets |
|
||||
| `shell-contract.md` | your surface needs to talk to the shell — focus, navigation, the agent sidebar |
|
||||
| `providers-and-roots.md` | your app needs a backend, files on disk, or a binary like ffmpeg |
|
||||
| `failure-modes.md` | **read this before you ship, not after.** Every entry is a real failure with the symptom you will actually see |
|
||||
|
||||
## The rule that saves the most time
|
||||
|
||||
**Verbs answering is not apps working, and only a browser catches the difference.**
|
||||
|
||||
Every API check can return 200 with real data while the pane renders empty — a field renamed, a
|
||||
vocabulary the icon map does not know, a bundle that never loaded. This was the single most
|
||||
common failure in building the first five apps.
|
||||
|
||||
**Open the app. Look at it. Click the thing.** A passing test suite is a shape that a working
|
||||
app also produces.
|
||||
|
||||
## Versions: the package and the contract move separately
|
||||
|
||||
`@openrig/studio` is at **0.3.0**; the **contract is 0.1**. Those are different numbers on
|
||||
purpose and you will trip on it once. Additive changes — new fields, new routes, the
|
||||
consumer-surface seam — bump the package and leave `contractVersion` alone. A surface built on
|
||||
stable items of contract 0.1 keeps working across package minors.
|
||||
|
||||
**Check compatibility the documented way:** capabilities present + same contract *major*. An
|
||||
exact-match check on `contractVersion` is safe but over-strict and will refuse runtimes that are
|
||||
guaranteed to work. Ask the running box:
|
||||
|
||||
```sh
|
||||
curl -s localhost:<port>/api/contract | jq '{contractVersion, capabilities}'
|
||||
```
|
||||
|
||||
## Status of this skill
|
||||
|
||||
Grounded in building and migrating the first five applications built on this SDK. Every failure mode listed is one that actually happened and was
|
||||
measured, not anticipated.
|
||||
|
||||
**One skill, two authorship perspectives** — consumer-side and producer-side, deliberately not
|
||||
two documents. The consumer half is the original and remains the spine: it is the half that knows
|
||||
what actually goes wrong when you build on this thing. Producer additions are marked `[producer]`
|
||||
and are limited to what the SDK side can state authoritatively — mechanism, version facts, and
|
||||
which behaviours are contract rather than accident. Where the two would disagree, the measured
|
||||
consumer experience wins and the producer note explains why the runtime does that.
|
||||
|
||||
If you are extending this: extend it here. A second copy in another repo is the drift this
|
||||
placement exists to prevent.
|
||||
@@ -0,0 +1,137 @@
|
||||
# app.json — declaring an app
|
||||
|
||||
One file beside your app's code. It is what lets a box install your app without a human
|
||||
reading your README and guessing.
|
||||
|
||||
## Minimum — an ultralight app
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"id": "notes",
|
||||
"name": "NOTES",
|
||||
"summary": "Scratch notes that live with the project.",
|
||||
"category": "create",
|
||||
"surface": {
|
||||
"entry": "app/notes.html",
|
||||
"path": "/surfaces/notes.html",
|
||||
"glyph": "▤",
|
||||
"hint": "jot and search"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`entry`** — where the file lives in YOUR repo.
|
||||
- **`path`** — where it is served on the rail. The composer copies `entry` → the overlay and
|
||||
serves it at `path`. These are two different things and conflating them is a common slip.
|
||||
- **`glyph`** — one character. The rail is dense; an emoji will look wrong beside the others.
|
||||
|
||||
## A heavy app
|
||||
|
||||
A heavy app serves itself. It has no `entry` to copy — only a URL to point at.
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"id": "beatline",
|
||||
"name": "BEATLINE",
|
||||
"surface": {
|
||||
"url": "http://127.0.0.1:8794/beatline.html",
|
||||
"glyph": "∿",
|
||||
"hint": "music-video beat timeline"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`path:` and `url:` are mutually exclusive.** `path:` means "the shell serves this file";
|
||||
`url:` means "something else already serves it, just frame it."
|
||||
|
||||
## The fields that make it installable
|
||||
|
||||
Everything above makes an app *appear*. These make it *work* on a machine that is not yours.
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": {
|
||||
"package": "@example/my-provider",
|
||||
"required": true
|
||||
},
|
||||
"roots": {
|
||||
"project": { "required": true },
|
||||
"media": { "required": true }
|
||||
},
|
||||
"verbs": ["/api/timelines", "/api/patch", "/api/probe-duration"],
|
||||
"vendor": ["tldraw-v5.2.5"]
|
||||
}
|
||||
```
|
||||
|
||||
| field | why it exists |
|
||||
|---|---|
|
||||
| `provider` | the backend your surface calls. **Omit it and installing your app yields an HTML file that 502s.** |
|
||||
| `roots` | the KINDS of directory you need — not paths. The install binds kinds to real locations. |
|
||||
| `verbs` | what you call. Makes the dependency auditable rather than discovered at runtime. |
|
||||
| `vendor` | directories that travel with the app. Ship them; do not fetch from a CDN. |
|
||||
|
||||
### roots are KINDS, never paths
|
||||
|
||||
```json
|
||||
"roots": { "media": { "required": true } } ✅ the install binds it
|
||||
"roots": { "media": "/Users/me/videos" } ❌ works on exactly one machine
|
||||
```
|
||||
|
||||
Every hardcoded path in the apps migrated so far existed because **nothing in the format ever
|
||||
asked where the user's data lives.** Declaring kinds kills the whole class.
|
||||
|
||||
### Declare the closure; do not discover it
|
||||
|
||||
List what your app needs. Do not expect an installer to find it by scanning your imports — a
|
||||
scan that matches `./` will miss `../../vendor/tldraw`, and the app will load to a spinner with
|
||||
every verb returning 200.
|
||||
|
||||
**An app is not its surface file.** It is surface + backend + vendored deps + a resolved
|
||||
closure + the roots it needs + first-run seeding.
|
||||
|
||||
## [producer] Where your surfaces actually live, and what the runtime will serve
|
||||
|
||||
Register from **outside** the package. Point the runtime at a directory you own:
|
||||
|
||||
```sh
|
||||
node node_modules/@openrig/studio/app/serve-studio.mjs --surfaces ./surfaces
|
||||
# or
|
||||
OPENRIG_STUDIO_SURFACES=./surfaces npm start
|
||||
```
|
||||
|
||||
That directory holds your pages **and** your own `surfaces.json`, using the same row schema.
|
||||
Nothing is ever written into the package, so a copied `node_modules` cannot carry your
|
||||
registrations — which is failure mode #8, removed structurally rather than mitigated.
|
||||
|
||||
Three consequences that will bite you if you assume otherwise:
|
||||
|
||||
**Serving follows registration, not the filesystem.** A file in your overlay is served **only**
|
||||
when a valid row registers it. Dropping `notes.html` next to your manifest does not publish it —
|
||||
it is a 404. So is anything else in that directory: a stray `secret.txt` is not reachable. The
|
||||
overlay is a registration seam, not a static web root.
|
||||
|
||||
**A registered row whose page is missing fails as itself.** You get a 404 naming the problem, not
|
||||
the package's page quietly substituted underneath your row. Row ownership and page ownership are
|
||||
never allowed to disagree about whose surface you are looking at.
|
||||
|
||||
**Replacing a stock surface is an ID act, and it warns.** Reuse the id and your row wins with a
|
||||
named warning in `manifest.warnings`. Reusing another row's `path` under a different id is
|
||||
refused with an error instead — otherwise replacement would become a silent filename act.
|
||||
|
||||
**With an overlay configured, the runtime serves exactly what is declared** — the SDK's own
|
||||
surfaces plus yours — and anything else in the installed package is ignored for serving and
|
||||
reported in `manifest.warnings`. That guarantee covers surface state written *into* a package
|
||||
(a copied tree, a stale materialisation, a dependency writing into `node_modules`). It does not
|
||||
cover a dependency shipping a modified runtime source; that is supply-chain, a higher bar you
|
||||
accept by installing at all.
|
||||
|
||||
## The test that matters
|
||||
|
||||
**Install the manifest somewhere that has nothing.**
|
||||
|
||||
A manifest written on a machine that already has everything will always under-declare — not
|
||||
through carelessness, but because nothing forced it to be honest. The first app to fail this
|
||||
way declared a surface and a screenshot, and its own verify step admitted the runtime provider
|
||||
was "not shipped in this package." It was listed in a registry as installable. It was not.
|
||||
@@ -0,0 +1,197 @@
|
||||
# Failure modes
|
||||
|
||||
Every entry below actually happened while building or migrating the first five apps on this
|
||||
SDK. They are ordered by how much time they cost. **The symptom is listed first, because the
|
||||
symptom is what you will have.**
|
||||
|
||||
The unifying shape: **a result whose form looks like an answer while its content is not one.**
|
||||
Almost every one of these produced HTTP 200, a clean exit, or a green check.
|
||||
|
||||
---
|
||||
|
||||
## 1. The pane is empty and every verb returns 200
|
||||
|
||||
**Cause:** the response shape does not match what the surface reads. A field renamed, a
|
||||
wrapper added, a vocabulary the UI does not know.
|
||||
|
||||
Three real instances, all in one afternoon, all from hand-rewriting working code:
|
||||
- returned `{dir, entries}` where the surface reads `{roots}` or `{path, dirs, files}`
|
||||
- read every file as `utf8`, corrupting video, and hung the pane
|
||||
- returned a `kind` vocabulary (`"file"`) the icon map had no entry for
|
||||
|
||||
**Fix:** treat the response shape as a **contract with the surface**, not an implementation
|
||||
detail. When porting, port — do not retype. Assert on the shape in a test, including the
|
||||
"no argument" case, because that is usually the one the UI actually calls first.
|
||||
|
||||
**Why it hides:** the verb genuinely works. Real data, correct status, sensible JSON.
|
||||
|
||||
---
|
||||
|
||||
## 2. The tab loads but sits on a spinner forever
|
||||
|
||||
**Cause:** a vendored dependency did not load. The surface's own verbs are all fine.
|
||||
|
||||
Real instance: an import scan that matched `./` missed `../../vendor/tldraw`, so the app was
|
||||
installed without its renderer.
|
||||
|
||||
**Fix:** declare the vendor closure in `app.json`. Do not discover it by scanning.
|
||||
|
||||
---
|
||||
|
||||
## 3. The app shows an empty state on a project that is not empty
|
||||
|
||||
**Cause:** your page is **not served from inside your project.** A relative
|
||||
`fetch("timeline.json")` resolves against the surfaces overlay, not your data directory.
|
||||
|
||||
Real instance: an app fetched a bare relative filename, which had always worked because its
|
||||
previous host served the page *out of the project bundle*. Under the SDK studio the shell
|
||||
serves surfaces from an overlay and only `/api/*` reaches the backend — so it 404'd on **every
|
||||
project**, fell back to an embedded sample, and reported success.
|
||||
|
||||
**Fix:** ask the backend for the document over `/api/…`. Never assume you are inside the data.
|
||||
|
||||
---
|
||||
|
||||
## 4. A load failure that looks exactly like a new project
|
||||
|
||||
**Cause:** a fallback that renders as a plausible empty state.
|
||||
|
||||
This is #3's second half and it is worse than #3. The fallback was sample data, so a failure rendered as a different project with broken media — which reads as
|
||||
"my project loaded wrong." Emptying the fallback fixed the lie and created a subtler one: a
|
||||
total failure to load became a clean empty board, indistinguishable from a fresh project.
|
||||
|
||||
**Fix:** **an empty state and a failed load must not share an appearance.** Name the failure,
|
||||
say what was tried, and withdraw any affordance that would write into data you are not showing.
|
||||
|
||||
---
|
||||
|
||||
## 5. A dependency answers, but it is the wrong one
|
||||
|
||||
**Cause:** a hardcoded default port, plus a degradation path that cannot tell "absent" from
|
||||
"answered wrong."
|
||||
|
||||
Real instance: a proxy read an env var or fell back to a hardcoded `127.0.0.1:<port>`. On a
|
||||
busy host something else was there. It returned `ok: true` with a **stranger's data** while the
|
||||
real service held the correct data.
|
||||
|
||||
**Fix:** bind dependencies explicitly. **A degradation path must distinguish "dependency
|
||||
absent" from "dependency answered wrong"** — otherwise graceful degradation is
|
||||
indistinguishable from misconfiguration, because both return 200.
|
||||
|
||||
---
|
||||
|
||||
## 6. The button says it worked and nothing happens
|
||||
|
||||
**Cause:** the write is real — data changes on disk — but the process that performs the effect
|
||||
is not running or not in scope.
|
||||
|
||||
Real instance: a verb appended a job for a render watcher, returned `ok: true`, and nothing
|
||||
ever rendered. **This defeats both obvious checks**: verbs-answer passes, and real-data-changed
|
||||
passes, because the job file genuinely gains a line.
|
||||
|
||||
**Fix:** the bar is **"the verb's real-world effect is observably performed,"** not "data
|
||||
changed." Observe the render, not the enqueue. If the performer is genuinely out of scope, make
|
||||
the control **visibly unavailable** — never a button returning success that silently does
|
||||
nothing.
|
||||
|
||||
---
|
||||
|
||||
## 7. Works on the author's machine, silently no-ops elsewhere
|
||||
|
||||
**Cause:** platform-specific shell behaviour inside a guard.
|
||||
|
||||
Real instance: a watcher script used `stat -c %Y` (GNU). On macOS (BSD `stat`) the call failed,
|
||||
the guard fell through, and **the lane never ran at all** — silently, for an unknown length of
|
||||
time.
|
||||
|
||||
**Fix:** no shell-outs for things a runtime does natively. If you must, test on both platforms
|
||||
or fail loud when the tool is not the one you expect.
|
||||
|
||||
---
|
||||
|
||||
## 8. A fresh-looking install serving another instance's content
|
||||
|
||||
**Cause:** state written into the installed package directory, then copied.
|
||||
|
||||
If a boot step writes into `node_modules`, then `node_modules` is **state**, not just
|
||||
dependencies. Copying it to skip a slow install carries another instance's registrations.
|
||||
|
||||
**Fix:** keep consumer state outside the package (the SDK's `--surfaces` seam exists for this).
|
||||
And: **an instance's rail is not proof of its commit** — healthy and correct are two claims.
|
||||
|
||||
---
|
||||
|
||||
## 9. The app is schema-invalid the moment it is created
|
||||
|
||||
**Cause:** a required field the creation path does not set — masked by a bigger failure
|
||||
upstream.
|
||||
|
||||
Real instance: every beat created from the UI lacked a required `template` field. Completely
|
||||
invisible, because the board never loaded a real project (see #3), so validation never ran
|
||||
against a UI-created record.
|
||||
|
||||
**Fix:** when you fix a load path, **re-run everything downstream of it.** Bugs hide behind
|
||||
bugs, and the second one only becomes reachable when the first is fixed.
|
||||
|
||||
---
|
||||
|
||||
## 10. Evidence collected from the wrong server
|
||||
|
||||
**Cause:** a port collision on a busy host.
|
||||
|
||||
A studio that lands on a stranger's port produces screenshots and API responses that are
|
||||
evidence for something you did not build.
|
||||
|
||||
**Fix:** fail loud on an occupied port; never attach to whatever is listening. Confirm with
|
||||
`lsof -nP -iTCP:<port> -sTCP:LISTEN`, the process args, and in-browser `location.origin`.
|
||||
|
||||
---
|
||||
|
||||
## 11. [producer] Your file is right there in the overlay and the URL 404s
|
||||
|
||||
**Cause:** no valid row registers it. Serving follows **registration**, not the filesystem —
|
||||
putting a file in your surfaces directory does not publish it.
|
||||
|
||||
The same rule catches the case you want caught: a stray `secret.txt` beside your manifest is not
|
||||
reachable either. The overlay is a registration seam, not a static web root.
|
||||
|
||||
**Fix:** check `/api/contract` → `manifest.errors[]`. If your row was rejected it is named there,
|
||||
row by row. A row that fails validation is excluded from the rail *and* its path stops resolving,
|
||||
which is why the symptom is a 404 rather than a missing tab.
|
||||
|
||||
**Related, and it surprises people:** a registered row whose page is missing returns a 404 naming
|
||||
the problem — the package's page is **not** substituted underneath your row. That is deliberate.
|
||||
Serving someone else's bytes under your row would make the rail and the page disagree about whose
|
||||
surface you are looking at.
|
||||
|
||||
---
|
||||
|
||||
## 12. [producer] The package is contaminated and your check says it is clean
|
||||
|
||||
**Cause:** you checked for **errors**. Contamination is reported as a **warning**.
|
||||
|
||||
With an overlay configured, the runtime serves exactly the SDK's own declared surfaces plus your
|
||||
overlay-declared ones. An undeclared row or file in the installed package — from a copied tree, a
|
||||
stale materialisation, a dependency writing into `node_modules` — is ignored for serving and
|
||||
named in `manifest.warnings`. It is deliberately *not* an error: it is not the consumer's fault
|
||||
and it must not stop the runtime serving.
|
||||
|
||||
So `manifest.ok === true` and `errors: []` on a **dirty** package is the correct, healthy-looking
|
||||
output. A monitor watching for errors sees nothing wrong.
|
||||
|
||||
**Fix:** **no-errors is not the clean signal — the warning's absence is.** Assert on
|
||||
`manifest.warnings` being empty, not just `manifest.errors`. This is the same shape as everything
|
||||
else on this page: the check's failure output is indistinguishable from its success output unless
|
||||
you look at the field that actually carries the answer.
|
||||
|
||||
---
|
||||
|
||||
## The checklist this reduces to
|
||||
|
||||
- Open the app in a browser and use it. Do not infer working from responding.
|
||||
- Ask of every check: **what does this look like when it fails?** If that resembles success,
|
||||
the check proves nothing — run a negative control that makes it fail on purpose.
|
||||
- Assert that the **legitimate** operation still works, not only that the bad one is rejected.
|
||||
A boundary that rejects everything passes every attack test and ships a dead feature.
|
||||
- Install your manifest somewhere that has nothing.
|
||||
- Never let a failure borrow an empty state's appearance.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Providers, roots, and the closure
|
||||
|
||||
The gap between "my app appears on the rail" and "my app works."
|
||||
|
||||
## Do you need a provider?
|
||||
|
||||
A **provider** is the backend your surface calls. Decide with one question:
|
||||
|
||||
> Does my surface call any verb the shell does not serve?
|
||||
|
||||
Check what the shell actually gives you — `curl /api/contract` → `capabilities`. At contract
|
||||
v0.1 that is a deliberately small set (contract meta, observe, an event stream, **read-only**
|
||||
file access, the shell protocol).
|
||||
|
||||
- **No** → ultralight. No provider. You ride the shell's verbs.
|
||||
- **Yes** → you need a provider, and **`app.json` must declare it.**
|
||||
|
||||
```json
|
||||
"provider": { "package": "@example/my-provider", "required": true }
|
||||
```
|
||||
|
||||
**Omit this and installing your app produces an HTML file that 502s.** That is not
|
||||
hypothetical — an app can be listed as installable while its manifest declares only a surface and a screenshot, with no provider shipped in the package.
|
||||
|
||||
## Do not extract a "generic core" prematurely
|
||||
|
||||
When your provider grows, the instinct is to split the reusable half out. Resist it until a
|
||||
**second consumer exists to show you where the seam is.**
|
||||
|
||||
Measured reason: hand-extracting a working backend broke three response contracts in one
|
||||
afternoon, each returning 200 with real data while the pane rendered empty. Doing that
|
||||
speculatively at 10× scale, with no second consumer, is the same operation with worse odds.
|
||||
|
||||
**Name it and package it as-is.** The seam reveals itself from demand.
|
||||
|
||||
## Roots are KINDS
|
||||
|
||||
Declare the kinds of directory you need. The install binds kinds to real paths.
|
||||
|
||||
```json
|
||||
"roots": {
|
||||
"project": { "required": true },
|
||||
"media": { "required": true }
|
||||
}
|
||||
```
|
||||
|
||||
Every hardcoded path found in the migrated apps — a specific project folder, one machine's working directory, `$HOME/studio/app` — existed for the same reason: **nothing in the format
|
||||
ever asked where the user's data lives.** They were not sloppiness. They were a missing
|
||||
convention, and declaring kinds removes the class permanently.
|
||||
|
||||
Corollary for your code: **parameterise the roots** (`--media-root`, `--project-root`) so
|
||||
binding is configuration, not a patch.
|
||||
|
||||
## Declare the closure
|
||||
|
||||
List everything your app needs to run:
|
||||
|
||||
- the provider package
|
||||
- vendored directories (`vendor: ["tldraw-v5.2.5"]`) — ship them; the box should not call a CDN
|
||||
- binaries you shell out to (`ffmpeg`, `ffprobe`) — **name the binary AND the verb that uses
|
||||
it**, so the dependency is auditable and cannot quietly grow by analogy
|
||||
- first-run seeding, if your app needs a scaffolded project to be usable
|
||||
|
||||
**Do not expect an installer to discover this by scanning your imports.** A scan matching `./`
|
||||
misses `../../vendor/…`, and the result is an app that loads to a spinner with every verb
|
||||
returning 200.
|
||||
|
||||
## First run
|
||||
|
||||
An app whose backend answers `ENOENT` on a fresh box is honest but unusable. Decide explicitly
|
||||
which you are shipping:
|
||||
|
||||
- **honestly empty** — no data yet, and a *working way to add some*. This is fine.
|
||||
- **stub empty** — returns `[]` regardless of what is on disk. This is not.
|
||||
|
||||
If your app needs a project structure to exist, seed it with the product's **own** scaffolder.
|
||||
Hand-writing a plausible-looking data file gives the app something that parses and then behaves
|
||||
wrong.
|
||||
|
||||
## Serving your own documents
|
||||
|
||||
If your surface needs to read your project's files, **serve them from your provider over
|
||||
`/api/…`.** Do not let the surface fetch them as siblings, and do not read them through some
|
||||
other backend's file verbs.
|
||||
|
||||
Both shortcuts work on the machine you are testing on and fail elsewhere:
|
||||
|
||||
- the sibling fetch works only while something serves your page from inside your data directory
|
||||
- another backend's file verbs work only while your data happens to sit inside *its* configured
|
||||
roots
|
||||
|
||||
The document belongs to the provider that owns it. **The path should be server-derived** — a
|
||||
read verb that takes no caller-supplied path cannot be pointed somewhere it should not go, and
|
||||
needs no validation to prove it.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Talking to the shell
|
||||
|
||||
**The contract ships inside the package. Read it there, not here** — restating it would create
|
||||
a second source that silently drifts:
|
||||
|
||||
| document | in `node_modules/@openrig/studio/contract/` |
|
||||
|---|---|
|
||||
| the shell protocol, lifecycle, postMessage, the change-signal loop | `shell-protocol.md` |
|
||||
| the row schema, field by field, with stability markers | `surface-row.schema.json` |
|
||||
| the runtime's own verbs | `runtime-api.md` |
|
||||
| what the contract version means | `contract-meta.md` |
|
||||
|
||||
Check the live contract of the box you are on:
|
||||
|
||||
```sh
|
||||
curl -s localhost:<port>/api/contract | jq '{contractVersion, capabilities, manifest}'
|
||||
```
|
||||
|
||||
## The short orientation
|
||||
|
||||
Your surface lives in an iframe and is **sovereign inside it**. The shell gives you four
|
||||
things, and only four:
|
||||
|
||||
1. **A frame and a rail tab.** Registered by a row; `path` (shell serves it) or `url`
|
||||
(something else does) — exactly one, never both.
|
||||
2. **A liveness dot**, from the shell polling your URL. You write no code for it. It means
|
||||
*answered HTTP*, nothing more — a dot is not evidence the app works.
|
||||
3. **An agent sidebar** you may summon with `postMessage`.
|
||||
4. **A change signal** (`GET /api/events`, SSE) — **a signal, not a data channel.** On any
|
||||
message, re-fetch what you care about. Do not parse payloads.
|
||||
|
||||
The surface→shell channel is a **closed** message set in v0.1 (`open-surface`, `open-agent`).
|
||||
The shell ignores anything else; new types arrive as contract additions, not by surfaces
|
||||
inventing them.
|
||||
|
||||
## Consumer-side notes the contract docs do not cover
|
||||
|
||||
**Your iframe is kept alive when backgrounded.** State and open connections survive a tab
|
||||
switch. If your surface holds an attachment that must not persist invisibly — a terminal
|
||||
client, a device handle — declare `unmountOnBlur: true` or a hidden instance will fight the
|
||||
visible one.
|
||||
|
||||
**Declare `color-scheme: dark`.** The shell is dark; a light flash on load reads as broken.
|
||||
|
||||
**Show degraded states in the UI, not the console.** When something is wrong it must *look*
|
||||
wrong. This is the same rule as `failure-modes.md` #4, stated by the contract itself.
|
||||
|
||||
**`chatSeats` had no consumer path at contract v0.1.** The runtime merges `surfaces[]` from a
|
||||
consumer overlay and passes the rest of the document through, so a consumer's declared seat
|
||||
roster was ignored and every install showed the SDK's own fixture seat in the agent sidebar.
|
||||
Verify what your box actually serves before trusting the roster in that panel; a consumer front
|
||||
that owns the origin can compose the real roster onto `/surfaces.json` itself. **Re-check
|
||||
whether this is still true on your contract version** rather than assuming either way.
|
||||
|
||||
> **[producer] Answering that re-check, as of package `0.3.0` / contract `0.1`: STILL TRUE, and
|
||||
> here is the mechanism so you can confirm it yourself rather than trust this line.**
|
||||
> `/surfaces.json` is served as `{ ...packageDocument, surfaces: <merged rows> }` — the package
|
||||
> document spread with only `surfaces` overridden. **The overlay document is never spread**, so
|
||||
> overlay `chatSeats` is read by nothing and the package's fixture roster reaches every install.
|
||||
>
|
||||
> **The part that will cost you time: it fails silently.** Putting `chatSeats` in your overlay is
|
||||
> the obvious move — it mirrors exactly how you register surfaces — and it produces no warning,
|
||||
> no error, and nothing at `/api/contract`. A plausible path that does nothing.
|
||||
>
|
||||
> This is a known defect with a ruled fix pending, not a design intent. The likely shape is that
|
||||
> the package roster stops leaking into consumer installs and `/api/contract` starts declaring
|
||||
> that seats are package-sourced and **not** overlay-merged — so the obvious attempt hits a
|
||||
> visible signal instead of silence. **Check `/api/contract` on your version before assuming
|
||||
> either behaviour.**
|
||||
>
|
||||
> One correction to the workaround above: composing the roster onto `/surfaces.json` yourself
|
||||
> works only if **you own the origin** — i.e. your own front serves that path. Under the SDK
|
||||
> runtime you do not, so there is no consumer-side fix on this version; the honest move is to
|
||||
> treat the sidebar roster as unreliable and say so in your UI rather than paper over it.
|
||||
>
|
||||
> The fixture seat **is labelled**: the sidebar renders it beside an explicit note that the
|
||||
> reference runtime has no live seat backing. Wrong for a real box, but it is a labelled fixture,
|
||||
> not a silent lie — do not report it as one.
|
||||
|
||||
**The fixture floor is fiction by design.** The SDK ships a `factory-state.json` describing an
|
||||
invented rig, rendered under a live-activity header with a green dot. Correct as an SDK
|
||||
example, actively misleading on a real box — generate the same contract from your real source
|
||||
and point the runtime at that.
|
||||
@@ -0,0 +1,600 @@
|
||||
// Regression suite for the consumer-surface-registration seam.
|
||||
//
|
||||
// node --test 'test/*.test.mjs'
|
||||
//
|
||||
// The seam exists so a consumer of this SDK can register ITS OWN surfaces from
|
||||
// outside the package. Before it, the only documented path pointed inside the
|
||||
// package — which in a real install is node_modules/@openrig/studio/app/ —
|
||||
// gitignored, wiped by npm ci, and carried by a copy: duplicating a
|
||||
// node_modules tree duplicated phantom surface state, so a contaminated
|
||||
// instance served 200 with a healthy manifest and the WRONG surfaces.
|
||||
//
|
||||
// So the tests below split into two claims that are NOT the same:
|
||||
//
|
||||
// REGISTRATION works — a consumer surface loads from outside the package.
|
||||
// THE CLASS IS GONE — there is ZERO consumer state inside the package, so
|
||||
// copying node_modules cannot inject a surface at all.
|
||||
//
|
||||
// The second is the one that matters and the one that is easy to pass by
|
||||
// accident while failing in spirit: any cache, copy or materialisation of
|
||||
// consumer surfaces into the package would satisfy the first and break the
|
||||
// second. It is tested directly rather than inferred from the first.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO = path.resolve(HERE, "..");
|
||||
|
||||
const CONSUMER_ROW = {
|
||||
id: "mine", name: "MINE", glyph: "▲", path: "/surfaces/mine.html", hint: "consumer-owned",
|
||||
};
|
||||
const CONSUMER_PAGE = "<!doctype html><html><head><title>MINE</title></head><body>consumer surface</body></html>";
|
||||
|
||||
const freePort = () => new Promise((resolve, reject) => {
|
||||
const s = net.createServer();
|
||||
s.once("error", reject);
|
||||
s.listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => resolve(port)); });
|
||||
});
|
||||
|
||||
// A throwaway copy of the runtime plus a SEPARATE consumer dir — the point of
|
||||
// the seam is that these two are not the same tree.
|
||||
function scaffold({ withConsumer = true } = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "seam-test-"));
|
||||
fs.cpSync(path.join(REPO, "app"), path.join(dir, "app"), { recursive: true });
|
||||
fs.cpSync(path.join(REPO, "fixtures"), path.join(dir, "fixtures"), { recursive: true });
|
||||
const consumer = path.join(dir, "consumer");
|
||||
if (withConsumer) {
|
||||
fs.mkdirSync(consumer);
|
||||
fs.writeFileSync(path.join(consumer, "surfaces.json"), JSON.stringify({ surfaces: [CONSUMER_ROW] }, null, 2));
|
||||
fs.writeFileSync(path.join(consumer, "mine.html"), CONSUMER_PAGE);
|
||||
}
|
||||
return { dir, consumer, appDir: path.join(dir, "app") };
|
||||
}
|
||||
|
||||
async function start(dir, args = []) {
|
||||
const port = await freePort();
|
||||
const proc = spawn(process.execPath, [path.join(dir, "app", "serve-studio.mjs"), "--port", String(port), ...args],
|
||||
{ cwd: dir, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let log = "";
|
||||
proc.stdout.on("data", (d) => { log += d; });
|
||||
proc.stderr.on("data", (d) => { log += d; });
|
||||
|
||||
const get = async (p) => fetch(`http://127.0.0.1:${port}${p}`, { cache: "no-store" });
|
||||
const contract = async () => (await (await get("/api/contract")).json()).manifest;
|
||||
|
||||
const deadline = Date.now() + 10_000;
|
||||
for (;;) {
|
||||
try { await contract(); break; } catch {
|
||||
if (Date.now() > deadline) { proc.kill(); throw new Error(`server never came up:\n${log}`); }
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
const until = async (pred, ms = 8000) => {
|
||||
const end = Date.now() + ms;
|
||||
let last;
|
||||
for (;;) {
|
||||
last = await contract();
|
||||
if (pred(last)) return last;
|
||||
if (Date.now() > end) return last;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
};
|
||||
return {
|
||||
port, get, contract, until, log: () => log,
|
||||
rows: async () => (await (await get("/surfaces.json")).json()).surfaces,
|
||||
text: async (p) => (await get(p)).text(),
|
||||
status: async (p) => (await get(p)).status,
|
||||
stop: () => proc.kill(),
|
||||
};
|
||||
}
|
||||
|
||||
// Every file under a root, relative — used to assert the package is untouched.
|
||||
function treeOf(root) {
|
||||
const out = [];
|
||||
const walk = (d, prefix) => {
|
||||
for (const name of fs.readdirSync(d).sort()) {
|
||||
const full = path.join(d, name);
|
||||
if (fs.statSync(full).isDirectory()) walk(full, path.join(prefix, name));
|
||||
else out.push(path.join(prefix, name));
|
||||
}
|
||||
};
|
||||
walk(root, "");
|
||||
return out;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- registration works
|
||||
|
||||
test("a consumer surface registers from OUTSIDE the package and is served", async (t) => {
|
||||
const { dir, consumer } = scaffold();
|
||||
const srv = await start(dir, ["--surfaces", consumer]);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
const m = await srv.contract();
|
||||
assert.equal(m.ok, true, JSON.stringify(m.errors));
|
||||
assert.equal(m.surfaces, 2, "expected FLOOR plus the consumer surface");
|
||||
assert.deepEqual((await srv.rows()).map((r) => r.id), ["floor", "mine"]);
|
||||
|
||||
// The rail must derive from the ARTIFACT, not merely look right: the served
|
||||
// bytes have to be the consumer's file.
|
||||
assert.equal(await srv.text("/surfaces/mine.html"), CONSUMER_PAGE);
|
||||
assert.equal(fs.readFileSync(path.join(consumer, "mine.html"), "utf8"), CONSUMER_PAGE);
|
||||
|
||||
assert.equal(m.consumer.dir, consumer);
|
||||
assert.equal(m.consumer.surfaces, 1);
|
||||
assert.equal(m.consumer.state, "ok");
|
||||
});
|
||||
|
||||
test("the env var registers the same way as the flag", async (t) => {
|
||||
const { dir, consumer } = scaffold();
|
||||
const port = await freePort();
|
||||
const proc = spawn(process.execPath, [path.join(dir, "app", "serve-studio.mjs"), "--port", String(port)],
|
||||
{ cwd: dir, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, OPENRIG_STUDIO_SURFACES: consumer } });
|
||||
t.after(() => proc.kill());
|
||||
|
||||
const deadline = Date.now() + 10_000;
|
||||
let m;
|
||||
for (;;) {
|
||||
try { m = (await (await fetch(`http://127.0.0.1:${port}/api/contract`)).json()).manifest; break; }
|
||||
catch { if (Date.now() > deadline) throw new Error("server never came up"); await new Promise((r) => setTimeout(r, 100)); }
|
||||
}
|
||||
assert.equal(m.surfaces, 2);
|
||||
assert.equal(m.consumer.dir, consumer);
|
||||
});
|
||||
|
||||
// ------------------------------------------- THE CLASS IS GONE
|
||||
|
||||
test("ZERO consumer state is written into the package — the package tree is byte-identical after serving", async (t) => {
|
||||
// This is the claim the slice exists for, and it is stronger than
|
||||
// "registration happens outside". A cache, copy or materialisation of
|
||||
// consumer surfaces into the package would pass every registration test
|
||||
// above and destroy the property.
|
||||
const { dir, consumer, appDir } = scaffold();
|
||||
|
||||
const before = treeOf(appDir).map((f) => [f, fs.readFileSync(path.join(appDir, f), "utf8")]);
|
||||
|
||||
const srv = await start(dir, ["--surfaces", consumer]);
|
||||
t.after(() => srv.stop());
|
||||
await srv.contract();
|
||||
await srv.text("/surfaces/mine.html"); // exercise the serving path
|
||||
await srv.rows();
|
||||
await new Promise((r) => setTimeout(r, 1500)); // let the integrity guard run
|
||||
|
||||
const after = treeOf(appDir).map((f) => [f, fs.readFileSync(path.join(appDir, f), "utf8")]);
|
||||
assert.deepEqual(after.map(([f]) => f), before.map(([f]) => f),
|
||||
"a file appeared in or vanished from the package tree");
|
||||
assert.deepEqual(after, before, "package file CONTENT changed — consumer state leaked into the package");
|
||||
|
||||
// And specifically: no trace of the consumer surface in the places consumer
|
||||
// state would actually materialise — the package manifest and the package's
|
||||
// surfaces dir. Scoped deliberately: a blob search across the whole tree also
|
||||
// matches this runtime's own source COMMENTS, which discuss consumer surfaces
|
||||
// at length and were there before the server ever started. That would be a
|
||||
// test failing on its own documentation rather than on a leak.
|
||||
const packageManifest = fs.readFileSync(path.join(appDir, "surfaces.json"), "utf8");
|
||||
assert.doesNotMatch(packageManifest, /"id":\s*"mine"/, "consumer ROW written into the package manifest");
|
||||
assert.equal(JSON.parse(packageManifest).surfaces.length, 1, "the package manifest gained a row");
|
||||
|
||||
const surfacesDir = path.join(appDir, "surfaces");
|
||||
for (const name of fs.readdirSync(surfacesDir)) {
|
||||
const body = fs.readFileSync(path.join(surfacesDir, name), "utf8");
|
||||
assert.doesNotMatch(body, /consumer surface/, `consumer PAGE content materialised into the package as ${name}`);
|
||||
}
|
||||
assert.ok(!fs.existsSync(path.join(surfacesDir, "mine.html")),
|
||||
"the consumer page was copied into the package");
|
||||
});
|
||||
|
||||
test("a PRE-CONTAMINATED package cannot inject — undeclared rows and pages are rejected and warned", async (t) => {
|
||||
// The guarantee : with the seam active the runtime
|
||||
// serves EXACTLY the SDK's own declared surfaces plus the consumer's
|
||||
// overlay-declared ones, and nothing else. Undeclared rows and files in the
|
||||
// package are ignored for serving AND warned, so contamination is visible
|
||||
// rather than silently masked.
|
||||
//
|
||||
// This test has been wrong twice, in two different ways, and both are worth
|
||||
// remembering:
|
||||
// 1. it once wrote a phantom PAGE and claimed to write a page AND a row, so
|
||||
// the assertion could not fail — a test that cannot fail is not a test;
|
||||
// 2. it once pinned the WEAK policy positively ("contaminated rows do
|
||||
// serve") as though that were the property. the prose
|
||||
// still said so after the assertions had been corrected. A passing suite
|
||||
// encoding a known-inadequate guarantee is worse than no test, because
|
||||
// it makes the inadequacy look ratified.
|
||||
// The prose and the assertions now say the same thing, deliberately.
|
||||
const origin = scaffold();
|
||||
|
||||
// contaminate the package the way materialisation used to: page AND row, in
|
||||
// the PACKAGE's own manifest
|
||||
fs.writeFileSync(path.join(origin.appDir, "surfaces", "phantom.html"), "<html>PHANTOM</html>");
|
||||
const pkgManifest = path.join(origin.appDir, "surfaces.json");
|
||||
const pkgDoc = JSON.parse(fs.readFileSync(pkgManifest, "utf8"));
|
||||
pkgDoc.surfaces.push({ id: "phantom", name: "PHANTOM", path: "/surfaces/phantom.html" });
|
||||
fs.writeFileSync(pkgManifest, JSON.stringify(pkgDoc, null, 2));
|
||||
|
||||
// copy the contaminated package into a FRESH deployment with a DIFFERENT consumer
|
||||
const dest = fs.mkdtempSync(path.join(os.tmpdir(), "seam-copy-"));
|
||||
fs.cpSync(origin.appDir, path.join(dest, "app"), { recursive: true });
|
||||
fs.cpSync(path.join(origin.dir, "fixtures"), path.join(dest, "fixtures"), { recursive: true });
|
||||
const freshConsumer = path.join(dest, "consumer");
|
||||
fs.mkdirSync(freshConsumer);
|
||||
fs.writeFileSync(path.join(freshConsumer, "surfaces.json"),
|
||||
JSON.stringify({ surfaces: [{ id: "theirs", name: "THEIRS", path: "/surfaces/theirs.html" }] }, null, 2));
|
||||
fs.writeFileSync(path.join(freshConsumer, "theirs.html"), "<html>theirs</html>");
|
||||
|
||||
const srv = await start(dest, ["--surfaces", freshConsumer]);
|
||||
t.after(() => srv.stop());
|
||||
const ids = (await srv.rows()).map((r) => r.id);
|
||||
|
||||
// WHAT THE SEAM DELIVERS: the origin deployment's CONSUMER surface did not
|
||||
// ride along, because it was never in the package to be copied.
|
||||
assert.ok(!ids.includes("mine"),
|
||||
"a consumer surface from the origin deployment rode along inside the copied package");
|
||||
assert.ok(ids.includes("theirs"), "the fresh consumer's own surface must be served");
|
||||
|
||||
// THE RAISED GUARANTEE : a pre-contaminated package
|
||||
// CANNOT inject. The earlier version of this test pinned the OPPOSITE as an
|
||||
// accepted limitation — that limitation is unacceptable, because a seam
|
||||
// that only prevents NEW contamination does not remove the class, which is
|
||||
// what this slice is for.
|
||||
assert.ok(!ids.includes("phantom"),
|
||||
"a phantom row injected into the PACKAGE manifest was served — the runtime must serve exactly " +
|
||||
"the SDK's own declared surfaces plus the consumer's overlay-declared ones, and nothing else");
|
||||
|
||||
// and the contamination must be VISIBLE rather than silently dropped, so an
|
||||
// operator learns the package is dirty instead of just not seeing a surface
|
||||
const m = await srv.contract();
|
||||
assert.ok(m.warnings.some((w) => /phantom/.test(w)),
|
||||
"undeclared package content must be WARNED, not silently ignored — masking it would hide the " +
|
||||
"contamination instead of surfacing it");
|
||||
|
||||
// the phantom PAGE must not be reachable either
|
||||
assert.equal(await srv.status("/surfaces/phantom.html"), 404,
|
||||
"the phantom page is still served even though its row was rejected");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- npm ci
|
||||
|
||||
test("npm ci does not wipe the consumer registration, and WOULD wipe an in-package one", async (t) => {
|
||||
// The discriminating pair. "Still there after npm ci" proves nothing unless
|
||||
// the old path is shown to be destroyed by the same command in the same run.
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "seam-npmci-"));
|
||||
const packed = execSync(`npm pack ${JSON.stringify(REPO)} --pack-destination ${JSON.stringify(dir)}`,
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim().split("\n").pop();
|
||||
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({
|
||||
name: "seam-consumer", version: "1.0.0", private: true,
|
||||
dependencies: { "@openrig/studio": `file:${path.join(dir, packed)}` },
|
||||
}, null, 2));
|
||||
execSync("npm install --silent", { cwd: dir, stdio: "ignore" });
|
||||
|
||||
const pkgApp = path.join(dir, "node_modules", "@openrig", "studio", "app");
|
||||
const consumer = path.join(dir, "surfaces");
|
||||
fs.mkdirSync(consumer);
|
||||
fs.writeFileSync(path.join(consumer, "surfaces.json"), JSON.stringify({ surfaces: [CONSUMER_ROW] }, null, 2));
|
||||
fs.writeFileSync(path.join(consumer, "mine.html"), CONSUMER_PAGE);
|
||||
|
||||
// the OLD path, for the negative control: a surface materialised INTO the package
|
||||
fs.writeFileSync(path.join(pkgApp, "surfaces", "legacy.html"), "<html>legacy</html>");
|
||||
assert.ok(fs.existsSync(path.join(pkgApp, "surfaces", "legacy.html")));
|
||||
|
||||
execSync("npm ci --silent", { cwd: dir, stdio: "ignore" });
|
||||
|
||||
// NEGATIVE CONTROL: the in-package surface is gone, so npm ci really did
|
||||
// recreate node_modules and the survival below means something.
|
||||
assert.ok(!fs.existsSync(path.join(pkgApp, "surfaces", "legacy.html")),
|
||||
"npm ci did not recreate node_modules — the survival assertion would be vacuous");
|
||||
|
||||
// the consumer's registration is untouched
|
||||
assert.ok(fs.existsSync(path.join(consumer, "surfaces.json")));
|
||||
assert.ok(fs.existsSync(path.join(consumer, "mine.html")));
|
||||
|
||||
const srv = await start(dir === dir ? path.join(dir, "node_modules", "@openrig", "studio") : dir,
|
||||
["--surfaces", consumer, "--fixtures", path.join(dir, "node_modules", "@openrig", "studio", "fixtures")]);
|
||||
t.after(() => srv.stop());
|
||||
const ids = (await srv.rows()).map((r) => r.id);
|
||||
assert.ok(ids.includes("mine"), "the consumer surface did not survive npm ci");
|
||||
});
|
||||
|
||||
// --------------------------------------------------- opt-in / non-breaking
|
||||
|
||||
test("FLOOR works with ZERO consumer config", async (t) => {
|
||||
// Proven by running that path, not by reasoning that it was not touched.
|
||||
const { dir } = scaffold({ withConsumer: false });
|
||||
const srv = await start(dir);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
const m = await srv.contract();
|
||||
assert.equal(m.ok, true);
|
||||
assert.equal(m.surfaces, 1, "FLOOR alone");
|
||||
assert.deepEqual((await srv.rows()).map((r) => r.id), ["floor"]);
|
||||
assert.equal(m.consumer, null, "the consumer block must be null when no overlay is configured");
|
||||
assert.equal(await srv.status("/surfaces/floor.html"), 200);
|
||||
});
|
||||
|
||||
test("zero-config validation errors are UNLABELLED, exactly as before the seam", async (t) => {
|
||||
// contract-meta lists changing an error contract as BREAKING, and a
|
||||
// zero-config consumer never opted into anything. Labels are for people who
|
||||
// configured a second manifest.
|
||||
const { dir, appDir } = scaffold({ withConsumer: false });
|
||||
fs.writeFileSync(path.join(appDir, "surfaces.json"), JSON.stringify({ surfaces: [{ name: "NO ID" }] }));
|
||||
const srv = await start(dir);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
const m = await srv.until((x) => x.errors.length > 0);
|
||||
assert.equal(m.errors[0], "surfaces[0]: id is required (non-empty string)");
|
||||
});
|
||||
|
||||
test("with an overlay, errors ARE source-labelled so a merged rail stays legible", async (t) => {
|
||||
const { dir, appDir, consumer } = scaffold();
|
||||
fs.writeFileSync(path.join(appDir, "surfaces.json"), JSON.stringify({ surfaces: [{ name: "NO ID" }] }));
|
||||
fs.writeFileSync(path.join(consumer, "surfaces.json"), JSON.stringify({ surfaces: [{ name: "ALSO NO ID" }] }));
|
||||
const srv = await start(dir, ["--surfaces", consumer]);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
// With enforcement active an undeclared PACKAGE row is dropped as
|
||||
// contamination BEFORE validation, so it surfaces as a labelled WARNING
|
||||
// rather than a validation error — a malformed row cannot deep-equal a
|
||||
// declared one, so it is undeclared by construction. The consumer's own
|
||||
// malformed row still errors, labelled. Both are labelled, which is what this
|
||||
// test is about: in a merged world a bare surfaces[i] identifies nothing.
|
||||
const m = await srv.until((x) => x.errors.length >= 1 && x.warnings.length >= 1);
|
||||
assert.ok(m.errors.some((e) => e.startsWith("consumer surfaces[0]")), JSON.stringify(m.errors));
|
||||
assert.ok(m.warnings.some((w) => w.startsWith("package surfaces[0]")), JSON.stringify(m.warnings));
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ precedence
|
||||
|
||||
test("a consumer row SHADOWS a package row of the same id — and says so", async (t) => {
|
||||
const { dir, consumer } = scaffold();
|
||||
fs.writeFileSync(path.join(consumer, "surfaces.json"), JSON.stringify({
|
||||
surfaces: [{ id: "floor", name: "MY FLOOR", path: "/surfaces/myfloor.html" }],
|
||||
}, null, 2));
|
||||
fs.writeFileSync(path.join(consumer, "myfloor.html"), "<html>my floor</html>");
|
||||
|
||||
const srv = await start(dir, ["--surfaces", consumer]);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
const rows = await srv.rows();
|
||||
assert.equal(rows.length, 1, "the shadowed package row must not also appear");
|
||||
assert.equal(rows[0].name, "MY FLOOR", "the consumer row must win");
|
||||
|
||||
const m = await srv.contract();
|
||||
assert.ok(m.warnings.some((w) => /SHADOWS the package surface/.test(w)),
|
||||
"shadowing must be named — silently replacing a surface is a silent replacement gives an operator no way to learn something was displaced");
|
||||
});
|
||||
|
||||
test("serving is REGISTRATION-aware: an unregistered consumer file never wins", async (t) => {
|
||||
// Regression: The overlay was resolved path-first, which made it a
|
||||
// public static root: any file under it answered /surfaces/<name> whether or
|
||||
// not a row registered it. Three consequences, all reproduced and all
|
||||
// asserted here.
|
||||
const { dir, consumer } = scaffold();
|
||||
fs.writeFileSync(path.join(consumer, "floor.html"), "CONSUMER-FLOOR-OVERRIDE"); // unregistered
|
||||
fs.writeFileSync(path.join(consumer, "secret.txt"), "SECRET"); // unregistered
|
||||
const srv = await start(dir, ["--surfaces", consumer]);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
// 1. an UNREGISTERED consumer file must not outrank a registered package page
|
||||
const floor = await srv.text("/surfaces/floor.html");
|
||||
assert.doesNotMatch(floor, /CONSUMER-FLOOR-OVERRIDE/,
|
||||
"an unregistered consumer file silently overrode the registered package page");
|
||||
|
||||
// 2. an unregistered file in the overlay must not be published at all
|
||||
assert.equal(await srv.status("/surfaces/secret.txt"), 404, "the overlay is acting as a public static root");
|
||||
assert.doesNotMatch(await srv.text("/surfaces/secret.txt"), /SECRET/);
|
||||
|
||||
// the registered consumer surface still works
|
||||
assert.equal(await srv.text("/surfaces/mine.html"), CONSUMER_PAGE);
|
||||
});
|
||||
|
||||
test("a registered consumer row with a MISSING page fails as itself, with no cross-owner fallback", async (t) => {
|
||||
// The third of QA's reproductions. Quietly serving the package's bytes under
|
||||
// the consumer's row would make row ownership and page ownership disagree —
|
||||
// a lie about what is being served, and precisely what the id-based, warned
|
||||
// shadow semantics exist to prevent.
|
||||
const { dir, consumer } = scaffold();
|
||||
fs.writeFileSync(path.join(consumer, "surfaces.json"), JSON.stringify({
|
||||
surfaces: [{ id: "floor", name: "MY FLOOR", path: "/surfaces/floor.html" }], // page NOT created
|
||||
}, null, 2));
|
||||
|
||||
const srv = await start(dir, ["--surfaces", consumer]);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
assert.equal(await srv.status("/surfaces/floor.html"), 404,
|
||||
"the package's FLOOR page was substituted under the consumer's row");
|
||||
const body = await srv.text("/surfaces/floor.html");
|
||||
assert.match(body, /registered for this path but its page is missing/);
|
||||
assert.doesNotMatch(body, /<!doctype html>/i, "package page bytes were served");
|
||||
});
|
||||
|
||||
test("the overlay serving root is contained — traversal is refused", async (t) => {
|
||||
const { dir, consumer } = scaffold();
|
||||
fs.writeFileSync(path.join(path.dirname(consumer), "secret.txt"), "SECRET");
|
||||
|
||||
const srv = await start(dir, ["--surfaces", consumer]);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
for (const attempt of ["/surfaces/../secret.txt", "/surfaces/..%2fsecret.txt", "/surfaces/../../etc/hosts"]) {
|
||||
const body = await srv.text(attempt);
|
||||
assert.doesNotMatch(body, /SECRET/, `traversal escaped the overlay root: ${attempt}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------- redeploy survival, for the CONSUMER
|
||||
|
||||
test("the CONSUMER manifest survives delete -> recreate with no restart", async (t) => {
|
||||
// The redeploy-survival guarantees have to hold for the consumer manifest too.
|
||||
// Consumer surfaces are what a real deployment actually redeploys, so this is
|
||||
// where the class does the most damage if it returns.
|
||||
const { dir, consumer } = scaffold();
|
||||
const srv = await start(dir, ["--surfaces", consumer]);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
const manifest = path.join(consumer, "surfaces.json");
|
||||
const saved = fs.readFileSync(manifest, "utf8");
|
||||
assert.equal((await srv.contract()).surfaces, 2);
|
||||
|
||||
fs.unlinkSync(manifest);
|
||||
let m = await srv.until((x) => x.consumer.state === "unreadable");
|
||||
assert.equal(m.consumer.state, "unreadable");
|
||||
assert.equal(m.surfaces, 1, "the rail falls back to FLOOR alone");
|
||||
|
||||
fs.writeFileSync(manifest, saved);
|
||||
m = await srv.until((x) => x.consumer.state === "ok");
|
||||
assert.equal(m.surfaces, 2, "the consumer manifest did not recover");
|
||||
assert.equal(m.consumer.recoveries, 1, "recovery must be reported positively for the consumer source");
|
||||
assert.match(srv.log(), /manifest RECOVERED:/);
|
||||
});
|
||||
|
||||
test("a CONSUMER directory swap is detected and served fresh", async (t) => {
|
||||
const { dir, consumer } = scaffold();
|
||||
const srv = await start(dir, ["--surfaces", consumer]);
|
||||
t.after(() => srv.stop());
|
||||
assert.deepEqual((await srv.rows()).map((r) => r.id), ["floor", "mine"]);
|
||||
|
||||
const incoming = path.join(dir, "consumer-next");
|
||||
fs.mkdirSync(incoming);
|
||||
fs.writeFileSync(path.join(incoming, "surfaces.json"), JSON.stringify({
|
||||
surfaces: [{ id: "swapped", name: "SWAPPED", path: "/surfaces/swapped.html" }],
|
||||
}, null, 2));
|
||||
fs.writeFileSync(path.join(incoming, "swapped.html"), "<html>swapped</html>");
|
||||
|
||||
fs.renameSync(consumer, path.join(dir, "consumer-old"));
|
||||
fs.renameSync(incoming, consumer);
|
||||
|
||||
const deadline = Date.now() + 8000;
|
||||
let ids;
|
||||
do {
|
||||
ids = (await srv.rows()).map((r) => r.id);
|
||||
if (ids.includes("swapped")) break;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
} while (Date.now() < deadline);
|
||||
|
||||
assert.deepEqual(ids, ["floor", "swapped"], "served stale consumer content after a directory swap");
|
||||
const m = await srv.contract();
|
||||
assert.ok(m.consumer.integrityReloads >= 1,
|
||||
"a consumer directory swap must be reported as an integrity reload, not corrected silently");
|
||||
assert.match(srv.log(), /manifest REPLACED:/);
|
||||
});
|
||||
|
||||
test("an idle overlay does not churn", async (t) => {
|
||||
const { dir, consumer } = scaffold();
|
||||
const srv = await start(dir, ["--surfaces", consumer]);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
const before = (await srv.contract()).consumer.reloads;
|
||||
await new Promise((r) => setTimeout(r, 3500));
|
||||
const after = (await srv.contract()).consumer;
|
||||
assert.equal(after.reloads, before, "the integrity guard reloaded an unchanged consumer manifest");
|
||||
assert.equal(after.integrityReloads, 0);
|
||||
});
|
||||
|
||||
test("positive control: this suite is capable of failing", () => {
|
||||
assert.throws(() => assert.equal(1, 2));
|
||||
});
|
||||
|
||||
// -------------------------------------------------- the authority root
|
||||
|
||||
test("the SDK's declared ROWS deep-equal what the package actually ships", () => {
|
||||
// DRIFT GUARD. the earlier id-only version was worthless: it
|
||||
// passed while FLOOR's name, glyph and path were all changed, because only
|
||||
// the id was compared. An authority that checks the id is bypassed by reusing
|
||||
// the id, so the guard has to check what the authority checks — the whole row.
|
||||
const src = fs.readFileSync(path.join(REPO, "app", "serve-studio.mjs"), "utf8");
|
||||
const block = src.match(/const SDK_DECLARED_SURFACES = \[([\s\S]*?)\n\];/)[1];
|
||||
const declared = JSON.parse("[" + block.replace(/(\w+):/g, '"$1":').replace(/,\s*$/, "") + "]");
|
||||
|
||||
const shipped = JSON.parse(fs.readFileSync(path.join(REPO, "app", "surfaces.json"), "utf8")).surfaces;
|
||||
|
||||
const canon = (rows) => rows
|
||||
.map((r) => JSON.stringify(Object.keys(r).sort().map((k) => [k, r[k]])))
|
||||
.sort();
|
||||
|
||||
assert.deepEqual(canon(declared), canon(shipped),
|
||||
"the SDK's in-code row declaration and its shipped manifest disagree. Declare SDK surfaces " +
|
||||
"EXACTLY as shipped — an id-level match is not enough, because the authority compares whole rows");
|
||||
});
|
||||
|
||||
test("reusing a DECLARED id with different content does not make a row authoritative", async (t) => {
|
||||
// With an id-only authority, an injected row reusing the
|
||||
// declared id served attacker content under a trusted name.
|
||||
const { dir, consumer, appDir } = scaffold();
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(appDir, "surfaces.json"), "utf8"));
|
||||
doc.surfaces.unshift({ id: "floor", name: "HIJACKED FLOOR", glyph: "X", path: "/surfaces/hijack.html" });
|
||||
fs.writeFileSync(path.join(appDir, "surfaces.json"), JSON.stringify(doc, null, 2));
|
||||
fs.writeFileSync(path.join(appDir, "surfaces", "hijack.html"), "HIJACK-PAGE");
|
||||
|
||||
const srv = await start(dir, ["--surfaces", consumer]);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
// BOTH halves, because asserting only rejection is what let the next defect
|
||||
// through: a PREPENDED forged row got the genuine FLOOR
|
||||
// rejected as its duplicate before the authority filter ran, so the forged
|
||||
// bytes were refused AND the authoritative surface vanished. The guarantee is
|
||||
// that the served set IS the declared set — a declared surface must SURVIVE
|
||||
// contamination, not merely fail to be replaced by it.
|
||||
assert.equal(await srv.status("/surfaces/hijack.html"), 404, "an injected row reusing a declared id was served");
|
||||
|
||||
const rows = await srv.rows();
|
||||
assert.ok(!rows.some((r) => r.name === "HIJACKED FLOOR"), "the hijacked row reached the rail");
|
||||
assert.ok(rows.some((r) => r.id === "floor" && r.name === "FLOOR"),
|
||||
"the GENUINE FLOOR row was suppressed by the forged one — contamination must not remove a declared surface");
|
||||
|
||||
const floor = await srv.text("/surfaces/floor.html");
|
||||
assert.match(floor, /<!doctype html>/i, "the genuine FLOOR page must still be served");
|
||||
|
||||
const m = await srv.contract();
|
||||
assert.ok(m.warnings.some((w) => /NOT declared by this SDK/.test(w)), "the injected row was not warned");
|
||||
assert.ok(!m.warnings.some((w) => /floor\.html: present but NOT declared/.test(w)),
|
||||
"the genuine FLOOR file was misclassified as undeclared");
|
||||
});
|
||||
|
||||
test("a consumer row may not steal a package-owned PATH with a different id", async (t) => {
|
||||
// Replacement is an ID act that warns; allowing a
|
||||
// different-id row to claim a package path made it a filename act that was
|
||||
// silent, and made both rail rows load consumer bytes.
|
||||
const { dir, consumer } = scaffold();
|
||||
fs.writeFileSync(path.join(consumer, "surfaces.json"), JSON.stringify({
|
||||
surfaces: [{ id: "other", name: "OTHER", path: "/surfaces/floor.html" }],
|
||||
}, null, 2));
|
||||
fs.writeFileSync(path.join(consumer, "floor.html"), "CONSUMER-BYTES");
|
||||
|
||||
const srv = await start(dir, ["--surfaces", consumer]);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
const body = await srv.text("/surfaces/floor.html");
|
||||
assert.doesNotMatch(body, /CONSUMER-BYTES/, "a different-id consumer row hijacked the package path");
|
||||
assert.match(body, /<!doctype html>/i, "the package FLOOR page must still be served");
|
||||
|
||||
const m = await srv.contract();
|
||||
assert.ok(m.errors.some((e) => /already owned by the package surface/.test(e)),
|
||||
"the colliding consumer row must be EXCLUDED with a named error");
|
||||
assert.ok(!m.warnings.some((w) => /floor\.html: present but NOT declared/.test(w)),
|
||||
"the genuine package FLOOR file must not be falsely reported as undeclared");
|
||||
});
|
||||
|
||||
test("with NO overlay, undeclared package content is served exactly as before", async (t) => {
|
||||
// The enforcement is opt-in with the seam. Applying it unconditionally would
|
||||
// change zero-config serving semantics — a vendored SDK that legitimately
|
||||
// added a row to its own manifest would stop serving it — and contract-meta
|
||||
// calls changing URL-resolution semantics breaking.
|
||||
const { dir, appDir } = scaffold({ withConsumer: false });
|
||||
fs.writeFileSync(path.join(appDir, "surfaces", "extra.html"), "<html>extra</html>");
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(appDir, "surfaces.json"), "utf8"));
|
||||
doc.surfaces.push({ id: "extra", name: "EXTRA", path: "/surfaces/extra.html" });
|
||||
fs.writeFileSync(path.join(appDir, "surfaces.json"), JSON.stringify(doc, null, 2));
|
||||
|
||||
const srv = await start(dir);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
const m = await srv.contract();
|
||||
assert.equal(m.surfaces, 2, "zero-config must still serve whatever the package manifest declares");
|
||||
assert.deepEqual((await srv.rows()).map((r) => r.id), ["floor", "extra"]);
|
||||
assert.equal(await srv.status("/surfaces/extra.html"), 200);
|
||||
assert.equal(m.warnings.length, 0, "no contamination warnings without an overlay");
|
||||
});
|
||||
@@ -0,0 +1,506 @@
|
||||
// Regression suite for redeploy-survival: the manifest watcher must survive the
|
||||
// manifest being DELETED AND RECREATED, and the recovery must be OBSERVABLE.
|
||||
//
|
||||
// node --test 'test/*.test.mjs'
|
||||
//
|
||||
// The defect: surfaces.json was watched BY PATH, so the watch bound to that
|
||||
// file's inode. Anything that REPLACES rather than edits the file — npm ci,
|
||||
// write-tmp+rename, a redeploy over an existing install, a save-by-rename
|
||||
// editor — produced a new inode, and the old watch never fired again. The
|
||||
// runtime sat on ENOENT indefinitely, serving an empty rail with HTTP 200,
|
||||
// until someone restarted the process.
|
||||
//
|
||||
// Why the assertions look the way they do: warn-first output (empty rail, named
|
||||
// error, 200) is honest AND is exactly what the stale-watcher bug looked like.
|
||||
// So "no error" proves nothing here. Every recovery assertion checks a POSITIVE
|
||||
// signal — the recoveries counter, lastRecoveryAt, and the log line — and every
|
||||
// one is paired with a negative control that must NOT report a recovery. A
|
||||
// counter that only ever increments would pass a one-sided test.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO = path.resolve(HERE, "..");
|
||||
const SERVER = path.join("app", "serve-studio.mjs");
|
||||
|
||||
const ONE = [{ id: "floor", name: "FLOOR", glyph: "●", path: "/surfaces/floor.html", hint: "one" }];
|
||||
const TWO = [...ONE, { id: "two", name: "TWO", glyph: "◆", path: "/surfaces/two.html", hint: "two" }];
|
||||
|
||||
const freePort = () => new Promise((resolve, reject) => {
|
||||
const s = net.createServer();
|
||||
s.once("error", reject);
|
||||
s.listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => resolve(port)); });
|
||||
});
|
||||
|
||||
// A throwaway copy of the runtime — never the repo's own app/, never a live
|
||||
// instance. serve-studio derives its paths from its own location, so a copy is
|
||||
// fully isolated.
|
||||
function instance(surfaces = ONE) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "redeploy-test-"));
|
||||
fs.cpSync(path.join(REPO, "app"), path.join(dir, "app"), { recursive: true });
|
||||
fs.cpSync(path.join(REPO, "fixtures"), path.join(dir, "fixtures"), { recursive: true });
|
||||
const manifestPath = path.join(dir, "app", "surfaces.json");
|
||||
const doc = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
doc.surfaces = surfaces;
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(doc, null, 2));
|
||||
return { dir, manifestPath, doc };
|
||||
}
|
||||
|
||||
function writeManifest(manifestPath, surfaces) {
|
||||
const doc = JSON.parse(fs.readFileSync(manifestPath, "utf8").trim() || "{}");
|
||||
doc.surfaces = surfaces;
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(doc, null, 2));
|
||||
}
|
||||
|
||||
async function start(dir) {
|
||||
const port = await freePort();
|
||||
const proc = spawn(process.execPath, [path.join(dir, SERVER), "--port", String(port)],
|
||||
{ cwd: dir, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let log = "";
|
||||
proc.stdout.on("data", (d) => { log += d; });
|
||||
proc.stderr.on("data", (d) => { log += d; });
|
||||
|
||||
const contract = async () => {
|
||||
const r = await fetch(`http://127.0.0.1:${port}/api/contract`, { cache: "no-store" });
|
||||
return (await r.json()).manifest;
|
||||
};
|
||||
|
||||
const deadline = Date.now() + 10_000;
|
||||
for (;;) {
|
||||
try { await contract(); break; } catch {
|
||||
if (Date.now() > deadline) { proc.kill(); throw new Error(`server never came up:\n${log}`); }
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
|
||||
// Bounded wait on CONTENT, not a fixed sleep — a sleep long enough to be
|
||||
// reliable is long enough to hide a slow regression.
|
||||
const until = async (predicate, ms = 8000) => {
|
||||
const end = Date.now() + ms;
|
||||
let last;
|
||||
for (;;) {
|
||||
last = await contract();
|
||||
if (predicate(last)) return last;
|
||||
if (Date.now() > end) return last;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
port, contract, until,
|
||||
log: () => log,
|
||||
text: async (p) => (await fetch(`http://127.0.0.1:${port}${p}`, { cache: "no-store" })).text(),
|
||||
status: async (p) => (await fetch(`http://127.0.0.1:${port}${p}`, { cache: "no-store" })).status,
|
||||
stop: () => proc.kill(),
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------- the A-G sequence
|
||||
|
||||
test("A-G: manifest survives delete -> recreate with no process restart", async (t) => {
|
||||
const { dir, manifestPath } = instance(ONE);
|
||||
const srv = await start(dir);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
// A — baseline. NEGATIVE CONTROL: booting healthy is not "recovering".
|
||||
let m = await srv.contract();
|
||||
assert.equal(m.ok, true);
|
||||
assert.equal(m.surfaces, 1);
|
||||
assert.equal(m.state, "ok");
|
||||
assert.equal(m.recoveries, 0, "a process that started healthy must report 0 recoveries");
|
||||
assert.equal(m.lastRecoveryAt, null);
|
||||
|
||||
// B — modify in place. Still not a recovery: it never broke.
|
||||
writeManifest(manifestPath, TWO);
|
||||
m = await srv.until((x) => x.surfaces === 2);
|
||||
assert.equal(m.surfaces, 2, "in-place edit was not picked up");
|
||||
assert.equal(m.recoveries, 0, "an ordinary edit must not be reported as a recovery");
|
||||
|
||||
// C — delete. Warn-first: named error, empty rail, still serving.
|
||||
const saved = fs.readFileSync(manifestPath, "utf8");
|
||||
fs.unlinkSync(manifestPath);
|
||||
m = await srv.until((x) => x.state === "unreadable");
|
||||
assert.equal(m.state, "unreadable");
|
||||
assert.equal(m.surfaces, 0);
|
||||
assert.match(m.errors[0], /cannot read surfaces\.json/);
|
||||
assert.equal(await srv.status("/"), 200, "runtime must keep serving through a missing manifest");
|
||||
|
||||
// D — recreate at the same path. THIS is the defect: pre-fix it stayed
|
||||
// ENOENT forever because the watch was bound to the deleted inode.
|
||||
fs.writeFileSync(manifestPath, saved);
|
||||
m = await srv.until((x) => x.state === "ok");
|
||||
assert.equal(m.state, "ok", "did not recover after delete -> recreate");
|
||||
assert.equal(m.surfaces, 2);
|
||||
|
||||
// The recovery must be ASSERTED, not inferred from the absence of an error.
|
||||
assert.equal(m.recoveries, 1, "recovery must be reported positively");
|
||||
assert.ok(m.lastRecoveryAt, "lastRecoveryAt must be set on recovery");
|
||||
assert.match(srv.log(), /manifest RECOVERED:/, "recovery must emit a log line");
|
||||
|
||||
// E — settle. Recovery is stable, not a flap.
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
m = await srv.contract();
|
||||
assert.equal(m.state, "ok");
|
||||
assert.equal(m.surfaces, 2);
|
||||
assert.equal(m.recoveries, 1, "a stable manifest must not keep incrementing recoveries");
|
||||
|
||||
// F — edit the RECREATED file. Proves the watch is genuinely live again
|
||||
// rather than having fired once.
|
||||
writeManifest(manifestPath, [...TWO.slice(0, 1), { ...TWO[1], name: "TWO-EDITED" }]);
|
||||
// Wait on the SERVED CONTENT, not on a count that is already satisfied — the
|
||||
// surface count is 2 both before and after this edit, so polling it would
|
||||
// return instantly and assert against a stale read.
|
||||
const deadline = Date.now() + 8000;
|
||||
let served;
|
||||
do {
|
||||
served = JSON.parse(await srv.text("/surfaces.json"));
|
||||
if (served.surfaces[1]?.name === "TWO-EDITED") break;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
} while (Date.now() < deadline);
|
||||
assert.equal(served.surfaces[1].name, "TWO-EDITED", "watch is not live after recreate");
|
||||
});
|
||||
|
||||
test("G control: a fresh process reads the same file fine", async (t) => {
|
||||
// The control that makes the whole sequence trustworthy — it distinguishes
|
||||
// "the watcher was stale" from "the data on disk was broken". Kept after the
|
||||
// fix for exactly the same reason.
|
||||
const { dir, manifestPath } = instance(ONE);
|
||||
writeManifest(manifestPath, TWO);
|
||||
const srv = await start(dir);
|
||||
t.after(() => srv.stop());
|
||||
const m = await srv.contract();
|
||||
assert.equal(m.ok, true);
|
||||
assert.equal(m.surfaces, 2);
|
||||
assert.equal(m.recoveries, 0);
|
||||
});
|
||||
|
||||
// ------------------------------------------------- replacement mechanisms
|
||||
|
||||
test("atomic write-tmp+rename is picked up", async (t) => {
|
||||
const { dir, manifestPath } = instance(ONE);
|
||||
const srv = await start(dir);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
const doc = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
doc.surfaces = TWO;
|
||||
const tmp = manifestPath + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify(doc, null, 2));
|
||||
fs.renameSync(tmp, manifestPath); // atomic replace: new inode, same path
|
||||
|
||||
const m = await srv.until((x) => x.surfaces === 2);
|
||||
assert.equal(m.surfaces, 2, "tmp+rename replace was not picked up");
|
||||
assert.equal(m.state, "ok");
|
||||
});
|
||||
|
||||
test("recovers when the manifest's parent directory is itself replaced", async (t) => {
|
||||
// The npm-ci shape: the whole install tree goes away, so the manifest AND its
|
||||
// parent directory are gone and there is briefly nothing to watch. Recovery
|
||||
// here depends on the bounded probe, not on a filesystem event.
|
||||
const { dir, manifestPath } = instance(ONE);
|
||||
const srv = await start(dir);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
const appDir = path.dirname(manifestPath);
|
||||
const stash = path.join(dir, "app-stash");
|
||||
fs.renameSync(appDir, stash);
|
||||
const m1 = await srv.until((x) => x.state === "unreadable");
|
||||
assert.equal(m1.state, "unreadable");
|
||||
|
||||
fs.renameSync(stash, appDir); // tree comes back, new directory inode
|
||||
const m2 = await srv.until((x) => x.state === "ok", 10_000);
|
||||
assert.equal(m2.state, "ok", "did not recover after the parent directory was replaced");
|
||||
assert.equal(m2.recoveries, 1);
|
||||
assert.match(srv.log(), /manifest RECOVERED:/);
|
||||
});
|
||||
|
||||
test("a directory SWAP is picked up, not served stale", async (t) => {
|
||||
// The nastiest shape, and the one no arrangement of watches can catch:
|
||||
// `mv current current.old && mv new current`. The watch keeps following the
|
||||
// OLD directory inode, which still exists under its new name, so no event
|
||||
// ever fires and no error is raised — the runtime would serve the PREVIOUS
|
||||
// manifest indefinitely while reporting state "ok". That is worse than the
|
||||
// ENOENT this slice started on, because it looks healthy.
|
||||
const { dir, manifestPath } = instance([{ id: "old", name: "OLD", glyph: "●", path: "/surfaces/floor.html" }]);
|
||||
const appDir = path.dirname(manifestPath);
|
||||
|
||||
// a complete replacement tree, as a deploy would stage
|
||||
const incoming = path.join(dir, "app-incoming");
|
||||
fs.cpSync(appDir, incoming, { recursive: true });
|
||||
writeManifest(path.join(incoming, "surfaces.json"),
|
||||
[{ id: "new", name: "NEW", glyph: "▲", path: "/surfaces/floor.html" },
|
||||
{ id: "new2", name: "NEW2", glyph: "◆", path: "/surfaces/floor.html" }]);
|
||||
|
||||
const srv = await start(dir);
|
||||
t.after(() => srv.stop());
|
||||
assert.deepEqual(JSON.parse(await srv.text("/surfaces.json")).surfaces.map((r) => r.id), ["old"]);
|
||||
|
||||
fs.renameSync(appDir, path.join(dir, "app-old"));
|
||||
fs.renameSync(incoming, appDir);
|
||||
|
||||
const deadline = Date.now() + 8000;
|
||||
let ids;
|
||||
do {
|
||||
ids = JSON.parse(await srv.text("/surfaces.json")).surfaces.map((r) => r.id);
|
||||
if (ids.join() === "new,new2") break;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
} while (Date.now() < deadline);
|
||||
|
||||
assert.deepEqual(ids, ["new", "new2"], "served stale content after a directory swap");
|
||||
const m = await srv.contract();
|
||||
assert.equal(m.state, "ok");
|
||||
|
||||
// Being corrected is not the same as being observable. A clean swap goes
|
||||
// ok -> ok, so no RECOVERY is reported — and if that were the only signal,
|
||||
// nothing would distinguish "the watch saw it" from "the watch missed it and
|
||||
// the stat floor caught it". The stale-but-healthy-looking lie has to be
|
||||
// observably DETECTED, not merely fixed.
|
||||
assert.equal(m.recoveries, 0, "a clean swap never broke, so it is not a recovery");
|
||||
assert.ok(m.integrityReloads >= 1,
|
||||
"a directory swap must be reported as an integrity reload — the watch cannot see it");
|
||||
assert.ok(m.lastIntegrityReloadAt, "lastIntegrityReloadAt must be set when the guard fires");
|
||||
assert.match(srv.log(), /manifest REPLACED:/,
|
||||
"the integrity guard must emit a positive log line, not correct silently");
|
||||
});
|
||||
|
||||
test("an idle runtime does not churn", async (t) => {
|
||||
// NEGATIVE CONTROL for the integrity guard: it stats every second, and it
|
||||
// must NOT reload a file that has not changed. Without this, "it reloads
|
||||
// eventually" could be passing for the wrong reason.
|
||||
const { dir } = instance(ONE);
|
||||
const srv = await start(dir);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
const before = (await srv.contract()).reloads;
|
||||
await new Promise((r) => setTimeout(r, 3500));
|
||||
const after = await srv.contract();
|
||||
|
||||
assert.equal(after.reloads, before, "the integrity guard reloaded an unchanged manifest");
|
||||
assert.equal(after.recoveries, 0);
|
||||
assert.equal(after.state, "ok");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------ startup robustness
|
||||
// The gap: every test above starts from a HEALTHY manifest, so an
|
||||
// entire class of failure — the runtime dying during boot — was invisible to
|
||||
// the suite. A redeploy is exactly when the manifest is most likely to be
|
||||
// missing as the process starts, so booting into that state has to work.
|
||||
|
||||
test("starts and warn-firsts when the manifest is ABSENT at boot", async (t) => {
|
||||
const { dir, manifestPath } = instance(ONE);
|
||||
fs.unlinkSync(manifestPath);
|
||||
|
||||
const srv = await start(dir); // start() throws if the process never serves
|
||||
t.after(() => srv.stop());
|
||||
|
||||
const m = await srv.contract();
|
||||
assert.equal(m.state, "unreadable");
|
||||
assert.match(m.errors[0], /cannot read surfaces\.json/);
|
||||
assert.equal(await srv.status("/"), 200, "the shell must be available with no manifest");
|
||||
|
||||
// and it must still recover once the file appears — a cold start into the
|
||||
// broken state must not disable the recovery path
|
||||
writeManifest2(manifestPath, TWO);
|
||||
const after = await srv.until((x) => x.state === "ok");
|
||||
assert.equal(after.state, "ok");
|
||||
assert.equal(after.surfaces, 2);
|
||||
assert.equal(after.recoveries, 1, "recovering from a boot-time failure is still a recovery");
|
||||
});
|
||||
|
||||
test("starts and warn-firsts when the manifest is INVALID at boot", async (t) => {
|
||||
const { dir, manifestPath } = instance(ONE);
|
||||
fs.writeFileSync(manifestPath, "{ not json at all");
|
||||
|
||||
const srv = await start(dir);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
const m = await srv.contract();
|
||||
assert.equal(m.state, "invalid");
|
||||
assert.match(m.errors[0], /not valid JSON/);
|
||||
assert.equal(await srv.status("/"), 200);
|
||||
assert.equal(m.recoveries, 0, "booting broken is not a recovery");
|
||||
});
|
||||
|
||||
test("starts when the manifest's whole directory is missing at boot", async (t) => {
|
||||
// The most hostile boot: mid-redeploy, the tree is not there yet.
|
||||
const { dir, manifestPath } = instance(ONE);
|
||||
fs.rmSync(path.dirname(manifestPath), { recursive: true, force: true });
|
||||
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
|
||||
fs.cpSync(path.join(REPO, "app", "serve-studio.mjs"), path.join(dir, SERVER));
|
||||
|
||||
const srv = await start(dir);
|
||||
t.after(() => srv.stop());
|
||||
const m = await srv.contract();
|
||||
assert.equal(m.state, "unreadable");
|
||||
assert.equal(await srv.status("/api/contract"), 200);
|
||||
});
|
||||
|
||||
// helper used by the boot tests: the manifest may not exist yet, so this one
|
||||
// does not try to read the previous document first
|
||||
function writeManifest2(manifestPath, surfaces) {
|
||||
fs.writeFileSync(manifestPath, JSON.stringify({ surfaces }, null, 2));
|
||||
}
|
||||
|
||||
// ------------------------------------------------ warn-first non-regression
|
||||
|
||||
test("a genuinely invalid manifest still warn-firsts and is NOT reported as recovered", async (t) => {
|
||||
// The discriminator that matters most: a fix which made everything look
|
||||
// recovered would be worse than the bug it replaced.
|
||||
const { dir, manifestPath } = instance(ONE);
|
||||
const srv = await start(dir);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
fs.writeFileSync(manifestPath, JSON.stringify({ surfaces: [{ name: "NO ID" }] }, null, 2));
|
||||
const m = await srv.until((x) => x.state === "invalid");
|
||||
|
||||
assert.equal(m.state, "invalid");
|
||||
assert.equal(m.ok, false);
|
||||
assert.equal(m.surfaces, 0, "an invalid row must be excluded from the served rail");
|
||||
assert.match(m.errors[0], /id is required/, "the error must be NAMED, not generic");
|
||||
assert.equal(m.recoveries, 0, "an invalid manifest must never be reported as a recovery");
|
||||
assert.equal(await srv.status("/"), 200, "the shell must stay available");
|
||||
});
|
||||
|
||||
test("malformed JSON is named and the runtime keeps serving", async (t) => {
|
||||
const { dir, manifestPath } = instance(ONE);
|
||||
const srv = await start(dir);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
fs.writeFileSync(manifestPath, "{ not json");
|
||||
const m = await srv.until((x) => x.state === "invalid");
|
||||
assert.match(m.errors[0], /not valid JSON/);
|
||||
assert.equal(await srv.status("/"), 200);
|
||||
});
|
||||
|
||||
test("unknown fields still warn rather than error (forward-compat preserved)", async (t) => {
|
||||
const { dir, manifestPath } = instance(ONE);
|
||||
const srv = await start(dir);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
writeManifest(manifestPath, [{ ...ONE[0], somethingNew: "from a later version" }]);
|
||||
const m = await srv.until((x) => x.warnings.length > 0);
|
||||
assert.equal(m.ok, true, "an unknown field must not be an error");
|
||||
assert.equal(m.surfaces, 1, "the row must still be served");
|
||||
assert.match(m.warnings[0], /unknown field/);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- contract v0.1
|
||||
|
||||
test("contract v0.1 is unchanged: same version, same existing fields, additive only", async (t) => {
|
||||
const { dir } = instance(ONE);
|
||||
const srv = await start(dir);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
const r = await fetch(`http://127.0.0.1:${srv.port}/api/contract`, { cache: "no-store" });
|
||||
const body = await r.json();
|
||||
|
||||
assert.equal(body.contractVersion, "0.1", "adding fields must not move the contract version");
|
||||
assert.deepEqual(body.capabilities,
|
||||
["contract.meta", "observe.factory-state", "stream.events", "files.read", "shell.protocol"]);
|
||||
for (const key of ["ok", "errors", "warnings", "surfaces"]) {
|
||||
assert.ok(key in body.manifest, `pre-existing manifest field ${key} disappeared`);
|
||||
}
|
||||
for (const key of ["state", "lastLoadedAt", "reloads", "recoveries", "lastRecoveryAt"]) {
|
||||
assert.ok(key in body.manifest, `additive observability field ${key} missing`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the SSE change-signal still fires on a manifest change", async (t) => {
|
||||
const { dir, manifestPath } = instance(ONE);
|
||||
const srv = await start(dir);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
const controller = new AbortController();
|
||||
t.after(() => controller.abort());
|
||||
const res = await fetch(`http://127.0.0.1:${srv.port}/api/events`, { signal: controller.signal });
|
||||
const reader = res.body.getReader();
|
||||
|
||||
const got = (async () => {
|
||||
// The deadline has to be ENFORCED, not merely stated. Awaiting read()
|
||||
// bare never returns if the event never arrives, so the loop could not
|
||||
// re-check its own deadline — the test would hang until the runner killed
|
||||
// it instead of failing with the reason it asserts. A stated timeout that
|
||||
// cannot fire is a check whose failure does not look like its failure.
|
||||
//
|
||||
const deadline = Date.now() + 8000;
|
||||
let seen = "";
|
||||
while (Date.now() < deadline) {
|
||||
// The losing timer must be CLEARED. An uncleared referenced timer keeps
|
||||
// the event loop alive for its full duration after the race resolves —
|
||||
// the suite still passes, it just silently gets slower, which is a cost
|
||||
// nobody attributes to the test that caused it.
|
||||
let timer;
|
||||
const timeout = new Promise((r) => { timer = setTimeout(() => r("__timeout__"), Math.max(0, deadline - Date.now())); });
|
||||
const next = await Promise.race([reader.read(), timeout]);
|
||||
clearTimeout(timer);
|
||||
if (next === "__timeout__") return false;
|
||||
if (next.done) break;
|
||||
seen += new TextDecoder().decode(next.value);
|
||||
if (seen.includes("data:")) return true;
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
writeManifest(manifestPath, TWO);
|
||||
assert.equal(await got, true, "no SSE change-signal after a manifest change");
|
||||
});
|
||||
|
||||
test("the documented /api/contract example lists exactly the fields the runtime serves", async (t) => {
|
||||
// Drift-proofing. Shipping a field while the canonical example still shows
|
||||
// its absence manufactures a lie in the document a cold agent reads FIRST —
|
||||
// and that is a defect nobody notices, because docs do not fail loudly. This
|
||||
// asserts the example against the live response so the two cannot separate
|
||||
// silently: add a field without documenting it and this test fails.
|
||||
const { dir } = instance(ONE);
|
||||
const srv = await start(dir);
|
||||
t.after(() => srv.stop());
|
||||
|
||||
const doc = fs.readFileSync(path.join(REPO, "contract", "contract-meta.md"), "utf8");
|
||||
const block = doc.match(/`GET \/api\/contract` returns:\n\n```json\n([\s\S]*?)\n```/);
|
||||
assert.ok(block, "contract-meta.md no longer contains a /api/contract example");
|
||||
const documented = JSON.parse(block[1]);
|
||||
|
||||
const live = await srv.contract();
|
||||
assert.deepEqual(
|
||||
Object.keys(documented.manifest).sort(),
|
||||
Object.keys(live).sort(),
|
||||
"the documented manifest example and the served manifest have diverged"
|
||||
);
|
||||
});
|
||||
|
||||
test("startup runs after EVERY module binding, so the TDZ class cannot return", () => {
|
||||
// This asserts the STRUCTURE the source comment claims, rather than trusting
|
||||
// the comment. The earlier arrangement put init partway down the module and
|
||||
// was "safe" only because the current call graph happened not to reach the
|
||||
// later bindings — a property that silently expires the moment loadManifest
|
||||
// or watchForChanges grows a new dependency. Two TDZ crashes came out of that
|
||||
// file before this was made structural, the second after the first was
|
||||
// "fixed" by hoisting the single binding that tripped.
|
||||
const src = fs.readFileSync(path.join(REPO, "app", "serve-studio.mjs"), "utf8").split("\n");
|
||||
|
||||
const declarations = src
|
||||
.map((line, i) => (/^(let|const)\s/.test(line) ? i + 1 : null))
|
||||
.filter(Boolean);
|
||||
const startup = src
|
||||
.map((line, i) => (/^(watchForChanges|loadManifest)\(\);\s*$/.test(line) ? i + 1 : null))
|
||||
.filter(Boolean);
|
||||
|
||||
assert.ok(startup.length >= 2, "expected the startup calls at module level");
|
||||
assert.ok(
|
||||
Math.min(...startup) > Math.max(...declarations),
|
||||
`startup runs at line ${Math.min(...startup)} but a module binding is declared at line ` +
|
||||
`${Math.max(...declarations)} — startup must follow EVERY declaration, not merely the ones ` +
|
||||
`it currently happens to touch`
|
||||
);
|
||||
});
|
||||
|
||||
test("positive control: this suite is capable of failing", () => {
|
||||
assert.throws(() => assert.equal(1, 2));
|
||||
});
|
||||
Reference in new issue
Block a user