@vincentt-xr/harness 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -0
- package/dist/client/HarnessProvider.d.ts +21 -0
- package/dist/client/HarnessProvider.js +64 -0
- package/dist/client/buffer.d.ts +28 -0
- package/dist/client/buffer.js +66 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.js +5 -0
- package/dist/client/instrument.d.ts +13 -0
- package/dist/client/instrument.js +160 -0
- package/dist/client/sampler.d.ts +11 -0
- package/dist/client/sampler.js +72 -0
- package/dist/client/serialize.d.ts +32 -0
- package/dist/client/serialize.js +99 -0
- package/dist/client/trace.d.ts +31 -0
- package/dist/client/trace.js +87 -0
- package/dist/mcp/backend.d.ts +52 -0
- package/dist/mcp/backend.js +146 -0
- package/dist/mcp/cli.d.ts +2 -0
- package/dist/mcp/cli.js +10 -0
- package/dist/mcp/diagnostics.d.ts +13 -0
- package/dist/mcp/diagnostics.js +61 -0
- package/dist/mcp/server.d.ts +14 -0
- package/dist/mcp/server.js +140 -0
- package/dist/preview/cloudflared.d.ts +13 -0
- package/dist/preview/cloudflared.js +37 -0
- package/dist/preview/index.d.ts +15 -0
- package/dist/preview/index.js +30 -0
- package/dist/relay/cli.d.ts +2 -0
- package/dist/relay/cli.js +7 -0
- package/dist/relay/server.d.ts +12 -0
- package/dist/relay/server.js +85 -0
- package/dist/relay/store.d.ts +13 -0
- package/dist/relay/store.js +68 -0
- package/dist/scaffold/index.d.ts +19 -0
- package/dist/scaffold/index.js +72 -0
- package/dist/shared/config.d.ts +33 -0
- package/dist/shared/config.js +76 -0
- package/dist/shared/events.d.ts +78 -0
- package/dist/shared/events.js +6 -0
- package/package.json +60 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `harness-relay` — start the diagnostics relay beside Vite. The preview loop
|
|
3
|
+
// spawns this (or you run it directly). Prints a line the tunnel step can read.
|
|
4
|
+
import { startRelay } from "./server.js";
|
|
5
|
+
const portArg = process.argv.indexOf("--port");
|
|
6
|
+
const port = portArg !== -1 ? Number(process.argv[portArg + 1]) : 7331;
|
|
7
|
+
startRelay({ port, onLog: (m) => console.log(`[harness-relay] ${m}`) });
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { EventStore } from "./store.js";
|
|
2
|
+
export interface RelayOptions {
|
|
3
|
+
port?: number;
|
|
4
|
+
/** WS path the client connects to (default matches HarnessProvider). */
|
|
5
|
+
path?: string;
|
|
6
|
+
capacity?: number;
|
|
7
|
+
onLog?: (msg: string) => void;
|
|
8
|
+
}
|
|
9
|
+
export declare function startRelay(opts?: RelayOptions): {
|
|
10
|
+
store: EventStore;
|
|
11
|
+
close: () => Promise<void>;
|
|
12
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The relay: a WebSocket + HTTP server that sits beside Vite. The phone's
|
|
2
|
+
// harness client pushes event batches over WS; the MCP server pulls via a
|
|
3
|
+
// localhost HTTP endpoint. Deliberately tiny — all the buffering/querying logic
|
|
4
|
+
// is in store.ts. One relay process per preview run; the cloudflared tunnel
|
|
5
|
+
// carries the WS path to the phone.
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
import { WebSocketServer } from "ws";
|
|
8
|
+
import { EventStore } from "./store.js";
|
|
9
|
+
export function startRelay(opts = {}) {
|
|
10
|
+
const port = opts.port ?? 7331;
|
|
11
|
+
const path = opts.path ?? "/__harness";
|
|
12
|
+
const store = new EventStore(opts.capacity);
|
|
13
|
+
const log = opts.onLog ?? (() => undefined);
|
|
14
|
+
const http = createServer((req, res) => handleHttp(req, res, store, log));
|
|
15
|
+
const wss = new WebSocketServer({ server: http, path });
|
|
16
|
+
wss.on("connection", (ws) => {
|
|
17
|
+
log("client connected");
|
|
18
|
+
ws.on("message", (data) => {
|
|
19
|
+
try {
|
|
20
|
+
const msg = JSON.parse(String(data));
|
|
21
|
+
if (msg.type === "events" && Array.isArray(msg.events)) {
|
|
22
|
+
store.ingest(msg.sessionId, msg.events);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// ignore malformed frames — a bad client must not crash the relay
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
ws.on("close", () => log("client disconnected"));
|
|
30
|
+
});
|
|
31
|
+
http.listen(port, () => log(`relay listening on :${port} (ws ${path}, http /query)`));
|
|
32
|
+
return {
|
|
33
|
+
store,
|
|
34
|
+
close: () => new Promise((resolve) => {
|
|
35
|
+
wss.close();
|
|
36
|
+
http.close(() => resolve());
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// The MCP server (running on the same laptop) GETs /query with the RelayQuery
|
|
41
|
+
// as JSON in the body or querystring. Localhost-only by deployment; no auth in
|
|
42
|
+
// the local dev loop (the platform phase adds it).
|
|
43
|
+
function handleHttp(req, res, store, log) {
|
|
44
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
45
|
+
if (url.pathname !== "/query") {
|
|
46
|
+
res.writeHead(404).end();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
readQuery(req, url)
|
|
50
|
+
.then((q) => {
|
|
51
|
+
const result = store.query(q);
|
|
52
|
+
res
|
|
53
|
+
.writeHead(200, { "content-type": "application/json" })
|
|
54
|
+
.end(JSON.stringify(result));
|
|
55
|
+
})
|
|
56
|
+
.catch((err) => {
|
|
57
|
+
log(`query error: ${err}`);
|
|
58
|
+
res.writeHead(400).end();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
async function readQuery(req, url) {
|
|
62
|
+
if (req.method === "POST") {
|
|
63
|
+
const body = await readBody(req);
|
|
64
|
+
return body ? JSON.parse(body) : {};
|
|
65
|
+
}
|
|
66
|
+
const q = {};
|
|
67
|
+
const p = url.searchParams;
|
|
68
|
+
if (p.get("sessionId"))
|
|
69
|
+
q.sessionId = p.get("sessionId");
|
|
70
|
+
if (p.get("kind"))
|
|
71
|
+
q.kind = p.get("kind");
|
|
72
|
+
if (p.get("since"))
|
|
73
|
+
q.since = Number(p.get("since"));
|
|
74
|
+
if (p.get("limit"))
|
|
75
|
+
q.limit = Number(p.get("limit"));
|
|
76
|
+
return q;
|
|
77
|
+
}
|
|
78
|
+
function readBody(req) {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
let data = "";
|
|
81
|
+
req.on("data", (c) => (data += c));
|
|
82
|
+
req.on("end", () => resolve(data));
|
|
83
|
+
req.on("error", reject);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { DiagEvent, RelayQuery, RelayResult } from "../shared/events.js";
|
|
2
|
+
export declare class EventStore {
|
|
3
|
+
private readonly capacity;
|
|
4
|
+
private readonly buf;
|
|
5
|
+
private readonly sessionsSeen;
|
|
6
|
+
private latest;
|
|
7
|
+
constructor(capacity?: number);
|
|
8
|
+
/** Ingest a batch from a client. Tracks the session and advances latestSeq. */
|
|
9
|
+
ingest(sessionId: string, events: DiagEvent[]): void;
|
|
10
|
+
/** Answer an MCP query against the buffer. Newest-biased when limited. */
|
|
11
|
+
query(q?: RelayQuery): RelayResult;
|
|
12
|
+
size(): number;
|
|
13
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The relay's memory: a bounded ring buffer of recent diagnostics events,
|
|
2
|
+
// queryable the way the MCP server asks ("network events since seq 40", "last
|
|
3
|
+
// 20 errors"). Pure and dependency-free so it is fully unit-testable; the WS
|
|
4
|
+
// server in server.ts is a thin wrapper that feeds this and answers queries
|
|
5
|
+
// from it.
|
|
6
|
+
export class EventStore {
|
|
7
|
+
capacity;
|
|
8
|
+
buf = [];
|
|
9
|
+
sessionsSeen = new Set();
|
|
10
|
+
latest = -1;
|
|
11
|
+
constructor(capacity = 5000) {
|
|
12
|
+
this.capacity = capacity;
|
|
13
|
+
}
|
|
14
|
+
/** Ingest a batch from a client. Tracks the session and advances latestSeq. */
|
|
15
|
+
ingest(sessionId, events) {
|
|
16
|
+
this.sessionsSeen.add(sessionId);
|
|
17
|
+
for (const e of events) {
|
|
18
|
+
// Tag ownership on the stored copy so cross-session queries can filter.
|
|
19
|
+
this.buf.push(withSession(e, sessionId));
|
|
20
|
+
if (e.seq > this.latest)
|
|
21
|
+
this.latest = e.seq;
|
|
22
|
+
}
|
|
23
|
+
// Ring: drop oldest beyond capacity.
|
|
24
|
+
if (this.buf.length > this.capacity) {
|
|
25
|
+
this.buf.splice(0, this.buf.length - this.capacity);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Answer an MCP query against the buffer. Newest-biased when limited. */
|
|
29
|
+
query(q = {}) {
|
|
30
|
+
let out = this.buf.filter((e) => {
|
|
31
|
+
if (q.sessionId && sessionOf(e) !== q.sessionId)
|
|
32
|
+
return false;
|
|
33
|
+
if (q.kind && e.kind !== q.kind)
|
|
34
|
+
return false;
|
|
35
|
+
if (q.since !== undefined && e.seq <= q.since)
|
|
36
|
+
return false;
|
|
37
|
+
return true;
|
|
38
|
+
});
|
|
39
|
+
if (q.limit !== undefined && out.length > q.limit) {
|
|
40
|
+
out = out.slice(out.length - q.limit); // keep the newest `limit`
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
events: out.map(stripSession),
|
|
44
|
+
latestSeq: this.latest,
|
|
45
|
+
sessions: [...this.sessionsSeen],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
size() {
|
|
49
|
+
return this.buf.length;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// The store tags each event with its owning session out-of-band (a symbol key)
|
|
53
|
+
// so it survives in the buffer without polluting the wire type. Queries strip
|
|
54
|
+
// it before returning.
|
|
55
|
+
const SESSION = Symbol("session");
|
|
56
|
+
function withSession(e, sessionId) {
|
|
57
|
+
return Object.assign(Object.create(Object.getPrototypeOf(e)), e, {
|
|
58
|
+
[SESSION]: sessionId,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function sessionOf(e) {
|
|
62
|
+
return e[SESSION];
|
|
63
|
+
}
|
|
64
|
+
function stripSession(e) {
|
|
65
|
+
const copy = { ...e };
|
|
66
|
+
delete copy[SESSION];
|
|
67
|
+
return copy;
|
|
68
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const TEMPLATE_REPO = "git@github.com:vincentt-xr/v2-template.git";
|
|
2
|
+
/** Injectable git runner (real git by default; a fake in tests avoids the network). */
|
|
3
|
+
export type GitRunner = (args: string[]) => Promise<void>;
|
|
4
|
+
/**
|
|
5
|
+
* True when `dir` has no app yet (no package.json) → safe to scaffold into.
|
|
6
|
+
* A directory that already holds an app is left untouched (project_create just binds).
|
|
7
|
+
*/
|
|
8
|
+
export declare function needsScaffold(dir: string): Promise<boolean>;
|
|
9
|
+
export interface ScaffoldOptions {
|
|
10
|
+
repo?: string;
|
|
11
|
+
/** Test seam — defaults to spawning real git. */
|
|
12
|
+
runGit?: GitRunner;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Scaffold the template into `targetDir`. Clones to a temp dir, strips `.git`,
|
|
16
|
+
* copies the files in (never overwriting anything already there), then inits a
|
|
17
|
+
* fresh repo. Throws a clear error if the clone fails (e.g. no git access).
|
|
18
|
+
*/
|
|
19
|
+
export declare function scaffoldFromTemplate(targetDir: string, opts?: ScaffoldOptions): Promise<void>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Scaffold a new app from the v2-template GitHub template as part of
|
|
2
|
+
// project_create. Shallow-clones the template, drops its git history, copies the
|
|
3
|
+
// files into the target, and inits a fresh repo the creator owns — the local-first
|
|
4
|
+
// equivalent of GitHub's "Use this template". Node-only (spawns git); not imported
|
|
5
|
+
// by browser consumers.
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promises as fs } from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
export const TEMPLATE_REPO = "git@github.com:vincentt-xr/v2-template.git";
|
|
13
|
+
const realGit = async (args) => {
|
|
14
|
+
await execFileAsync("git", args);
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* True when `dir` has no app yet (no package.json) → safe to scaffold into.
|
|
18
|
+
* A directory that already holds an app is left untouched (project_create just binds).
|
|
19
|
+
*/
|
|
20
|
+
export async function needsScaffold(dir) {
|
|
21
|
+
try {
|
|
22
|
+
await fs.access(path.join(dir, "package.json"));
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Scaffold the template into `targetDir`. Clones to a temp dir, strips `.git`,
|
|
31
|
+
* copies the files in (never overwriting anything already there), then inits a
|
|
32
|
+
* fresh repo. Throws a clear error if the clone fails (e.g. no git access).
|
|
33
|
+
*/
|
|
34
|
+
export async function scaffoldFromTemplate(targetDir, opts = {}) {
|
|
35
|
+
const repo = opts.repo ?? TEMPLATE_REPO;
|
|
36
|
+
const runGit = opts.runGit ?? realGit;
|
|
37
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "vincentt-tmpl-"));
|
|
38
|
+
try {
|
|
39
|
+
try {
|
|
40
|
+
await runGit(["clone", "--depth", "1", repo, tmp]);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
44
|
+
throw new Error(`Could not clone the ${repo} template (check your git access): ${msg}`);
|
|
45
|
+
}
|
|
46
|
+
await fs.rm(path.join(tmp, ".git"), { recursive: true, force: true });
|
|
47
|
+
// Copy template contents in without clobbering anything already in the target.
|
|
48
|
+
for (const entry of await fs.readdir(tmp)) {
|
|
49
|
+
await fs.cp(path.join(tmp, entry), path.join(targetDir, entry), {
|
|
50
|
+
recursive: true,
|
|
51
|
+
force: false,
|
|
52
|
+
errorOnExist: false,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// Fresh history the creator owns (skip if the target is already a repo).
|
|
56
|
+
if (!(await isGitRepo(targetDir))) {
|
|
57
|
+
await runGit(["-C", targetDir, "init"]);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function isGitRepo(dir) {
|
|
65
|
+
try {
|
|
66
|
+
await execFileAsync("git", ["-C", dir, "rev-parse", "--git-dir"]);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface MachineConfig {
|
|
2
|
+
/** Base URL of the Vincentt backend API (e.g. https://api.vincentt.studio). */
|
|
3
|
+
apiUrl: string;
|
|
4
|
+
/** Personal access token (bearer) for machine auth. */
|
|
5
|
+
pat: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ProjectBinding {
|
|
8
|
+
/** The backend Project id this working tree publishes to. */
|
|
9
|
+
projectId: string;
|
|
10
|
+
/** The project's slug — its <slug>.<apex> host. Informational (server-authoritative). */
|
|
11
|
+
slug: string;
|
|
12
|
+
/** Optional per-project API override; else the machine config's apiUrl. */
|
|
13
|
+
apiUrl?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare const MACHINE_CONFIG_PATH: string;
|
|
16
|
+
export declare function projectBindingPath(cwd: string): string;
|
|
17
|
+
export declare function loadMachineConfig(): Promise<Partial<MachineConfig>>;
|
|
18
|
+
export declare function loadProjectBinding(cwd: string): Promise<ProjectBinding | undefined>;
|
|
19
|
+
/**
|
|
20
|
+
* Write the per-tree binding and make sure `.vincentt/` is gitignored (so the
|
|
21
|
+
* secret-free-but-tenant-scoped binding never rides a commit or `git archive`).
|
|
22
|
+
*/
|
|
23
|
+
export declare function writeProjectBinding(cwd: string, binding: ProjectBinding): Promise<string>;
|
|
24
|
+
export interface ResolvedConfig {
|
|
25
|
+
apiUrl: string;
|
|
26
|
+
pat: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the API URL + PAT for a machine call. Precedence: a project binding's
|
|
30
|
+
* apiUrl wins for the URL; env vars override the machine-config file; the file is
|
|
31
|
+
* the base. Throws a clear, actionable error when no PAT is configured.
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveConfig(cwd: string): Promise<ResolvedConfig>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Config the lifecycle MCP verbs (project_create / project_publish) read to reach
|
|
2
|
+
// a creator's Vincentt backend. Two homes, by sensitivity:
|
|
3
|
+
// • ~/.vincentt/config.json — machine-global { apiUrl, pat }. The PAT is a
|
|
4
|
+
// bearer credential, so it lives OUTSIDE any repo (never committed).
|
|
5
|
+
// • <project>/.vincentt/project.json — the per-tree binding { projectId, slug }.
|
|
6
|
+
// Gitignored: `git archive HEAD` (the remix path) must NOT carry it, or a
|
|
7
|
+
// remixer would inherit — and publish over — the original's project.
|
|
8
|
+
// Env vars (VINCENTT_API_URL / VINCENTT_PAT) override the file for local/dogfood
|
|
9
|
+
// runs so a creator can point at a dev backend without editing the machine config.
|
|
10
|
+
import { promises as fs } from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
export const MACHINE_CONFIG_PATH = path.join(os.homedir(), ".vincentt", "config.json");
|
|
14
|
+
const BINDING_DIR = ".vincentt";
|
|
15
|
+
const BINDING_FILE = "project.json";
|
|
16
|
+
export function projectBindingPath(cwd) {
|
|
17
|
+
return path.join(cwd, BINDING_DIR, BINDING_FILE);
|
|
18
|
+
}
|
|
19
|
+
async function readJsonIfExists(p) {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(await fs.readFile(p, "utf8"));
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
if (err.code === "ENOENT")
|
|
25
|
+
return undefined;
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function loadMachineConfig() {
|
|
30
|
+
return (await readJsonIfExists(MACHINE_CONFIG_PATH)) ?? {};
|
|
31
|
+
}
|
|
32
|
+
export async function loadProjectBinding(cwd) {
|
|
33
|
+
return readJsonIfExists(projectBindingPath(cwd));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Write the per-tree binding and make sure `.vincentt/` is gitignored (so the
|
|
37
|
+
* secret-free-but-tenant-scoped binding never rides a commit or `git archive`).
|
|
38
|
+
*/
|
|
39
|
+
export async function writeProjectBinding(cwd, binding) {
|
|
40
|
+
await fs.mkdir(path.join(cwd, BINDING_DIR), { recursive: true });
|
|
41
|
+
const p = projectBindingPath(cwd);
|
|
42
|
+
await fs.writeFile(p, JSON.stringify(binding, null, 2) + "\n", "utf8");
|
|
43
|
+
await ensureGitignored(cwd, `${BINDING_DIR}/`);
|
|
44
|
+
return p;
|
|
45
|
+
}
|
|
46
|
+
/** Append a pattern to the repo's .gitignore if not already present (best-effort). */
|
|
47
|
+
async function ensureGitignored(cwd, pattern) {
|
|
48
|
+
const gitignore = path.join(cwd, ".gitignore");
|
|
49
|
+
try {
|
|
50
|
+
const current = await fs.readFile(gitignore, "utf8").catch(() => "");
|
|
51
|
+
const has = current.split(/\r?\n/).some((l) => l.trim() === pattern.trim());
|
|
52
|
+
if (has)
|
|
53
|
+
return;
|
|
54
|
+
const next = current && !current.endsWith("\n") ? `${current}\n${pattern}\n` : `${current}${pattern}\n`;
|
|
55
|
+
await fs.writeFile(gitignore, next, "utf8");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// A missing/unwritable .gitignore must not fail project creation.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the API URL + PAT for a machine call. Precedence: a project binding's
|
|
63
|
+
* apiUrl wins for the URL; env vars override the machine-config file; the file is
|
|
64
|
+
* the base. Throws a clear, actionable error when no PAT is configured.
|
|
65
|
+
*/
|
|
66
|
+
export async function resolveConfig(cwd) {
|
|
67
|
+
const machine = await loadMachineConfig();
|
|
68
|
+
const binding = await loadProjectBinding(cwd);
|
|
69
|
+
const apiUrl = binding?.apiUrl ?? process.env.VINCENTT_API_URL ?? machine.apiUrl ?? "http://localhost:5051";
|
|
70
|
+
const pat = process.env.VINCENTT_PAT ?? machine.pat;
|
|
71
|
+
if (!pat) {
|
|
72
|
+
throw new Error(`No Vincentt access token found. Add {"apiUrl":"…","pat":"…"} to ${MACHINE_CONFIG_PATH}, ` +
|
|
73
|
+
`or set the VINCENTT_PAT env var.`);
|
|
74
|
+
}
|
|
75
|
+
return { apiUrl: apiUrl.replace(/\/$/, ""), pat };
|
|
76
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Monotonic-ish wall-clock ms since epoch, stamped by the client at capture. */
|
|
2
|
+
export type Timestamp = number;
|
|
3
|
+
/** Kinds of thing the diagnostics limb observes. */
|
|
4
|
+
export type DiagEventKind = "log" | "network" | "trace";
|
|
5
|
+
interface DiagEventBase {
|
|
6
|
+
kind: DiagEventKind;
|
|
7
|
+
/** Client capture time (Date.now on the phone). */
|
|
8
|
+
t: Timestamp;
|
|
9
|
+
/** Monotonic per-session sequence, so the agent can ask "since seq N". */
|
|
10
|
+
seq: number;
|
|
11
|
+
}
|
|
12
|
+
/** A console.* call, forwarded verbatim (args pre-serialized to strings). */
|
|
13
|
+
export interface LogEvent extends DiagEventBase {
|
|
14
|
+
kind: "log";
|
|
15
|
+
level: "log" | "info" | "warn" | "error" | "debug";
|
|
16
|
+
/** console args joined + safely stringified on the client (no cyclic refs). */
|
|
17
|
+
message: string;
|
|
18
|
+
/** First stack frame if the client could cheaply capture one (errors). */
|
|
19
|
+
origin?: string;
|
|
20
|
+
}
|
|
21
|
+
/** One fetch/XHR round trip, recorded on settle (success or failure). */
|
|
22
|
+
export interface NetworkEvent extends DiagEventBase {
|
|
23
|
+
kind: "network";
|
|
24
|
+
method: string;
|
|
25
|
+
url: string;
|
|
26
|
+
/** HTTP status, or 0 if the request never got one (network error, CORS). */
|
|
27
|
+
status: number;
|
|
28
|
+
/** Wall-clock duration ms, request start → response settle. */
|
|
29
|
+
durationMs: number;
|
|
30
|
+
/** Set when the request threw rather than returning a response. */
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A performance sample window. The client samples cheap in-page perf signals
|
|
35
|
+
* (long tasks, frame intervals, user marks) over a short window and ships one
|
|
36
|
+
* TraceEvent summarizing it — NOT a full DevTools trace. This is the tier that
|
|
37
|
+
* replaces hand-exporting a .json.gz.
|
|
38
|
+
*/
|
|
39
|
+
export interface TraceEvent extends DiagEventBase {
|
|
40
|
+
kind: "trace";
|
|
41
|
+
/** Window covered by this sample, ms. */
|
|
42
|
+
windowMs: number;
|
|
43
|
+
/** Frames-per-second observed in the window (from rAF intervals). */
|
|
44
|
+
fps: number;
|
|
45
|
+
/** Longest single main-thread task in the window, ms (0 if none seen). */
|
|
46
|
+
longestTaskMs: number;
|
|
47
|
+
/** Count of tasks over 50ms (the "long task" threshold) in the window. */
|
|
48
|
+
longTaskCount: number;
|
|
49
|
+
/** performance.mark names seen in the window, for correlating app phases. */
|
|
50
|
+
marks: string[];
|
|
51
|
+
}
|
|
52
|
+
export type DiagEvent = LogEvent | NetworkEvent | TraceEvent;
|
|
53
|
+
/**
|
|
54
|
+
* Envelope the client sends over the socket. A batch, because the client
|
|
55
|
+
* coalesces events into flushes rather than one socket message per console.log.
|
|
56
|
+
*/
|
|
57
|
+
export interface ClientToRelay {
|
|
58
|
+
type: "events";
|
|
59
|
+
/** Which app/session these belong to — one preview run = one sessionId. */
|
|
60
|
+
sessionId: string;
|
|
61
|
+
events: DiagEvent[];
|
|
62
|
+
}
|
|
63
|
+
/** What the MCP server asks the relay for. `since` is an exclusive seq cursor. */
|
|
64
|
+
export interface RelayQuery {
|
|
65
|
+
sessionId?: string;
|
|
66
|
+
kind?: DiagEventKind;
|
|
67
|
+
since?: number;
|
|
68
|
+
/** Cap the returned count (newest-biased); relay clamps to its buffer size. */
|
|
69
|
+
limit?: number;
|
|
70
|
+
}
|
|
71
|
+
export interface RelayResult {
|
|
72
|
+
events: DiagEvent[];
|
|
73
|
+
/** Highest seq the relay currently holds, so the caller can advance `since`. */
|
|
74
|
+
latestSeq: number;
|
|
75
|
+
/** Sessions the relay has seen events for (so the agent can disambiguate). */
|
|
76
|
+
sessions: string[];
|
|
77
|
+
}
|
|
78
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The wire contract between the three harness parts: the in-app client (which
|
|
2
|
+
// produces events on the phone), the relay (which buffers them), and the MCP
|
|
3
|
+
// server (which serves them to the agent). All three import THIS file, so the
|
|
4
|
+
// three can never drift on the shape of an event. Nothing here imports React,
|
|
5
|
+
// node, or MCP — it is pure data.
|
|
6
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vincentt-xr/harness",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Vincentt AR dev-loop harness — in-app diagnostics client + relay + agent-agnostic MCP server",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"files": [
|
|
9
|
+
"/dist"
|
|
10
|
+
],
|
|
11
|
+
"main": "./dist/client/index.js",
|
|
12
|
+
"module": "./dist/client/index.js",
|
|
13
|
+
"types": "./dist/client/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/client/index.d.ts",
|
|
17
|
+
"import": "./dist/client/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./relay": {
|
|
20
|
+
"types": "./dist/relay/server.d.ts",
|
|
21
|
+
"import": "./dist/relay/server.js"
|
|
22
|
+
},
|
|
23
|
+
"./preview": {
|
|
24
|
+
"types": "./dist/preview/index.d.ts",
|
|
25
|
+
"import": "./dist/preview/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"harness-relay": "./dist/relay/cli.js",
|
|
30
|
+
"harness-mcp": "./dist/mcp/cli.js"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"react": "^18.2.0"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
37
|
+
"cloudflared": "^0.7.1",
|
|
38
|
+
"ws": "^8.18.0",
|
|
39
|
+
"zod": "^3.23.8"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^22.9.0",
|
|
43
|
+
"@types/react": "^18.3.12",
|
|
44
|
+
"@types/ws": "^8.5.13",
|
|
45
|
+
"prettier": "3.3.3",
|
|
46
|
+
"ts-node": "^10.9.2",
|
|
47
|
+
"typescript": "^5.6.3",
|
|
48
|
+
"vitest": "^2.1.5"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsc -p tsconfig.build.json",
|
|
52
|
+
"relay": "node --loader ts-node/esm src/relay/cli.ts",
|
|
53
|
+
"mcp": "node --loader ts-node/esm src/mcp/cli.ts",
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"test:watch": "vitest",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"format:check": "prettier --check .",
|
|
58
|
+
"format": "prettier --write ."
|
|
59
|
+
}
|
|
60
|
+
}
|