@nebutra/agent-runtime 0.2.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.
Files changed (179) hide show
  1. package/.turbo/turbo-build.log +115 -0
  2. package/.turbo/turbo-test.log +44 -0
  3. package/.turbo/turbo-typecheck.log +4 -0
  4. package/CHANGELOG.md +253 -0
  5. package/LICENSE +676 -0
  6. package/README.md +50 -0
  7. package/dist/adapters/dispatcher-sse.d.ts +68 -0
  8. package/dist/adapters/dispatcher-sse.js +11 -0
  9. package/dist/adapters/dispatcher-sse.js.map +1 -0
  10. package/dist/adapters/index.d.ts +12 -0
  11. package/dist/adapters/index.js +21 -0
  12. package/dist/adapters/index.js.map +1 -0
  13. package/dist/adapters/mcp-catalog.d.ts +58 -0
  14. package/dist/adapters/mcp-catalog.js +9 -0
  15. package/dist/adapters/mcp-catalog.js.map +1 -0
  16. package/dist/adapters/prisma-rollout.d.ts +60 -0
  17. package/dist/adapters/prisma-rollout.js +7 -0
  18. package/dist/adapters/prisma-rollout.js.map +1 -0
  19. package/dist/chunk-24ZXP7FI.js +93 -0
  20. package/dist/chunk-24ZXP7FI.js.map +1 -0
  21. package/dist/chunk-2DA6Q6TN.js +126 -0
  22. package/dist/chunk-2DA6Q6TN.js.map +1 -0
  23. package/dist/chunk-37BBB2P2.js +73 -0
  24. package/dist/chunk-37BBB2P2.js.map +1 -0
  25. package/dist/chunk-57W3AR43.js +52 -0
  26. package/dist/chunk-57W3AR43.js.map +1 -0
  27. package/dist/chunk-5N4644PB.js +67 -0
  28. package/dist/chunk-5N4644PB.js.map +1 -0
  29. package/dist/chunk-5YS7WAPS.js +177 -0
  30. package/dist/chunk-5YS7WAPS.js.map +1 -0
  31. package/dist/chunk-6EGG2OZC.js +13 -0
  32. package/dist/chunk-6EGG2OZC.js.map +1 -0
  33. package/dist/chunk-7BUOF367.js +126 -0
  34. package/dist/chunk-7BUOF367.js.map +1 -0
  35. package/dist/chunk-BJBBR3QA.js +121 -0
  36. package/dist/chunk-BJBBR3QA.js.map +1 -0
  37. package/dist/chunk-CGRCUKGT.js +73 -0
  38. package/dist/chunk-CGRCUKGT.js.map +1 -0
  39. package/dist/chunk-FUG5DT2C.js +75 -0
  40. package/dist/chunk-FUG5DT2C.js.map +1 -0
  41. package/dist/chunk-LO24VOA3.js +199 -0
  42. package/dist/chunk-LO24VOA3.js.map +1 -0
  43. package/dist/chunk-MUF7ZZTO.js +57 -0
  44. package/dist/chunk-MUF7ZZTO.js.map +1 -0
  45. package/dist/chunk-NN7DATXA.js +46 -0
  46. package/dist/chunk-NN7DATXA.js.map +1 -0
  47. package/dist/chunk-PGGWSUTM.js +33 -0
  48. package/dist/chunk-PGGWSUTM.js.map +1 -0
  49. package/dist/chunk-RDKYDMXT.js +135 -0
  50. package/dist/chunk-RDKYDMXT.js.map +1 -0
  51. package/dist/chunk-YYFPDBJG.js +63 -0
  52. package/dist/chunk-YYFPDBJG.js.map +1 -0
  53. package/dist/chunk-ZMYX5VBU.js +135 -0
  54. package/dist/chunk-ZMYX5VBU.js.map +1 -0
  55. package/dist/chunk-ZTSKS42I.js +131 -0
  56. package/dist/chunk-ZTSKS42I.js.map +1 -0
  57. package/dist/commands.d.ts +74 -0
  58. package/dist/commands.js +10 -0
  59. package/dist/commands.js.map +1 -0
  60. package/dist/definitions.d.ts +94 -0
  61. package/dist/definitions.js +15 -0
  62. package/dist/definitions.js.map +1 -0
  63. package/dist/dispatcher.d.ts +50 -0
  64. package/dist/dispatcher.js +8 -0
  65. package/dist/dispatcher.js.map +1 -0
  66. package/dist/durable-turn.d.ts +58 -0
  67. package/dist/durable-turn.js +9 -0
  68. package/dist/durable-turn.js.map +1 -0
  69. package/dist/hook-pipeline.d.ts +114 -0
  70. package/dist/hook-pipeline.js +13 -0
  71. package/dist/hook-pipeline.js.map +1 -0
  72. package/dist/index.d.ts +1874 -0
  73. package/dist/index.js +3117 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/loop.d.ts +78 -0
  76. package/dist/loop.js +9 -0
  77. package/dist/loop.js.map +1 -0
  78. package/dist/mcp-bridge.d.ts +48 -0
  79. package/dist/mcp-bridge.js +8 -0
  80. package/dist/mcp-bridge.js.map +1 -0
  81. package/dist/model.d.ts +154 -0
  82. package/dist/model.js +9 -0
  83. package/dist/model.js.map +1 -0
  84. package/dist/policy.d.ts +130 -0
  85. package/dist/policy.js +23 -0
  86. package/dist/policy.js.map +1 -0
  87. package/dist/protocol.d.ts +170 -0
  88. package/dist/protocol.js +15 -0
  89. package/dist/protocol.js.map +1 -0
  90. package/dist/rollout-store-persistent.d.ts +48 -0
  91. package/dist/rollout-store-persistent.js +9 -0
  92. package/dist/rollout-store-persistent.js.map +1 -0
  93. package/dist/rollout.d.ts +82 -0
  94. package/dist/rollout.js +15 -0
  95. package/dist/rollout.js.map +1 -0
  96. package/dist/sandbox.d.ts +65 -0
  97. package/dist/sandbox.js +15 -0
  98. package/dist/sandbox.js.map +1 -0
  99. package/dist/skills.d.ts +93 -0
  100. package/dist/skills.js +10 -0
  101. package/dist/skills.js.map +1 -0
  102. package/dist/subagents.d.ts +129 -0
  103. package/dist/subagents.js +21 -0
  104. package/dist/subagents.js.map +1 -0
  105. package/dist/tools.d.ts +77 -0
  106. package/dist/tools.js +9 -0
  107. package/dist/tools.js.map +1 -0
  108. package/package.json +74 -0
  109. package/src/adapters/dispatcher-sse.test.ts +218 -0
  110. package/src/adapters/dispatcher-sse.ts +222 -0
  111. package/src/adapters/index.ts +18 -0
  112. package/src/adapters/mcp-catalog.test.ts +213 -0
  113. package/src/adapters/mcp-catalog.ts +188 -0
  114. package/src/adapters/prisma-rollout.test.ts +153 -0
  115. package/src/adapters/prisma-rollout.ts +104 -0
  116. package/src/agent-runtime.test.ts +176 -0
  117. package/src/artifact-stream.test.ts +330 -0
  118. package/src/artifact-stream.ts +453 -0
  119. package/src/channel-gateway.test.ts +432 -0
  120. package/src/channel-gateway.ts +357 -0
  121. package/src/code-review.test.ts +501 -0
  122. package/src/code-review.ts +495 -0
  123. package/src/command-suggestions.test.ts +251 -0
  124. package/src/command-suggestions.ts +338 -0
  125. package/src/commands.test.ts +184 -0
  126. package/src/commands.ts +140 -0
  127. package/src/commit-message.test.ts +249 -0
  128. package/src/commit-message.ts +180 -0
  129. package/src/context-compaction.test.ts +522 -0
  130. package/src/context-compaction.ts +434 -0
  131. package/src/definitions.test.ts +78 -0
  132. package/src/definitions.ts +190 -0
  133. package/src/deployment-status.test.ts +215 -0
  134. package/src/deployment-status.ts +227 -0
  135. package/src/design-context.test.ts +195 -0
  136. package/src/design-context.ts +198 -0
  137. package/src/dispatcher.test.ts +234 -0
  138. package/src/dispatcher.ts +189 -0
  139. package/src/durable-turn.test.ts +209 -0
  140. package/src/durable-turn.ts +135 -0
  141. package/src/edit-planner.test.ts +204 -0
  142. package/src/edit-planner.ts +325 -0
  143. package/src/fuzzy-match.test.ts +311 -0
  144. package/src/fuzzy-match.ts +444 -0
  145. package/src/hook-pipeline.test.ts +279 -0
  146. package/src/hook-pipeline.ts +373 -0
  147. package/src/inbound-admission.test.ts +394 -0
  148. package/src/inbound-admission.ts +246 -0
  149. package/src/index.ts +47 -0
  150. package/src/loop.test.ts +161 -0
  151. package/src/loop.ts +211 -0
  152. package/src/mcp-bridge.test.ts +165 -0
  153. package/src/mcp-bridge.ts +76 -0
  154. package/src/memory-provider.test.ts +232 -0
  155. package/src/memory-provider.ts +257 -0
  156. package/src/model.ts +168 -0
  157. package/src/permission-ruleset.test.ts +301 -0
  158. package/src/permission-ruleset.ts +200 -0
  159. package/src/policy.ts +151 -0
  160. package/src/project-repo.test.ts +232 -0
  161. package/src/project-repo.ts +311 -0
  162. package/src/protocol.ts +159 -0
  163. package/src/rollout-store-persistent.test.ts +217 -0
  164. package/src/rollout-store-persistent.ts +166 -0
  165. package/src/rollout.ts +150 -0
  166. package/src/sandbox.ts +113 -0
  167. package/src/session-share.test.ts +360 -0
  168. package/src/session-share.ts +310 -0
  169. package/src/skill-distillation.test.ts +177 -0
  170. package/src/skill-distillation.ts +369 -0
  171. package/src/skills.test.ts +277 -0
  172. package/src/skills.ts +255 -0
  173. package/src/subagents.test.ts +290 -0
  174. package/src/subagents.ts +332 -0
  175. package/src/tools.ts +126 -0
  176. package/src/workbench.test.ts +0 -0
  177. package/src/workbench.ts +0 -0
  178. package/tsconfig.json +12 -0
  179. package/tsup.config.ts +33 -0
@@ -0,0 +1,232 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import {
3
+ appendJsonlLine,
4
+ CONVERSATIONS_DIR,
5
+ type CommitRef,
6
+ emptyProjectMetadata,
7
+ type GitHostPort,
8
+ METADATA_PATH,
9
+ type OwnsRepoPredicate,
10
+ ProjectRepo,
11
+ ProjectRepoError,
12
+ parseJsonl,
13
+ } from "./project-repo.js";
14
+
15
+ /**
16
+ * Fake git host: content-addressed by branch HEAD plus per-sha snapshots.
17
+ * Faithfully models "every project is a git repo": writeCommit appends a
18
+ * commit to the branch and freezes a full file snapshot under its sha.
19
+ */
20
+ class FakeGitHost implements GitHostPort {
21
+ defaultBranch = "main";
22
+ // repoId -> branch -> ordered commit shas
23
+ private branches = new Map<string, Map<string, string[]>>();
24
+ // repoId -> sha -> full file snapshot
25
+ private snapshots = new Map<string, Map<string, Record<string, string>>>();
26
+ private commitMeta = new Map<string, CommitRef>();
27
+ public writeCount = 0;
28
+ private seq = 0;
29
+
30
+ async getDefaultBranch(): Promise<string> {
31
+ return this.defaultBranch;
32
+ }
33
+
34
+ private head(repoId: string, branch: string): string | null {
35
+ const shas = this.branches.get(repoId)?.get(branch);
36
+ if (!shas || shas.length === 0) return null;
37
+ return shas[shas.length - 1] ?? null;
38
+ }
39
+
40
+ async readFile(repoId: string, path: string, ref?: string | undefined): Promise<string | null> {
41
+ const sha = ref ?? this.head(repoId, this.defaultBranch);
42
+ if (!sha) return null;
43
+ const snap = this.snapshots.get(repoId)?.get(sha);
44
+ if (!snap) return null;
45
+ return snap[path] ?? null;
46
+ }
47
+
48
+ async writeCommit(
49
+ repoId: string,
50
+ branch: string,
51
+ files: Record<string, string>,
52
+ message: string,
53
+ ): Promise<CommitRef> {
54
+ this.writeCount += 1;
55
+ const prevSha = this.head(repoId, branch);
56
+ const prevSnap = (prevSha ? this.snapshots.get(repoId)?.get(prevSha) : undefined) ?? {};
57
+ const nextSnap = { ...prevSnap, ...files };
58
+ this.seq += 1;
59
+ const sha = `sha-${this.seq}`;
60
+ if (!this.snapshots.has(repoId)) this.snapshots.set(repoId, new Map());
61
+ this.snapshots.get(repoId)!.set(sha, nextSnap);
62
+ if (!this.branches.has(repoId)) this.branches.set(repoId, new Map());
63
+ const bmap = this.branches.get(repoId)!;
64
+ if (!bmap.has(branch)) bmap.set(branch, []);
65
+ bmap.get(branch)!.push(sha);
66
+ const ref: CommitRef = {
67
+ sha,
68
+ message,
69
+ date: new Date(2026, 0, this.seq).toISOString(),
70
+ };
71
+ this.commitMeta.set(sha, ref);
72
+ return ref;
73
+ }
74
+
75
+ async listCommits(
76
+ repoId: string,
77
+ branch: string,
78
+ limit?: number | undefined,
79
+ ): Promise<CommitRef[]> {
80
+ const shas = this.branches.get(repoId)?.get(branch) ?? [];
81
+ const refs = [...shas]
82
+ .reverse()
83
+ .map((s) => this.commitMeta.get(s))
84
+ .filter((r): r is CommitRef => Boolean(r));
85
+ return typeof limit === "number" ? refs.slice(0, limit) : refs;
86
+ }
87
+ }
88
+
89
+ const T = "tenant-1";
90
+ const REPO = "tenant-1/proj-a";
91
+ const ownsAll: OwnsRepoPredicate = () => true;
92
+
93
+ function makeRepo(host: FakeGitHost, owns: OwnsRepoPredicate = ownsAll): ProjectRepo {
94
+ return new ProjectRepo({ host, ownsRepo: owns });
95
+ }
96
+
97
+ describe("pure helpers", () => {
98
+ it("parseJsonl tolerates blank and trailing newline", () => {
99
+ expect(parseJsonl("")).toEqual([]);
100
+ expect(parseJsonl("\n\n")).toEqual([]);
101
+ expect(parseJsonl('{"a":1}\n{"b":2}\n')).toEqual([{ a: 1 }, { b: 2 }]);
102
+ expect(parseJsonl('{"a":1}')).toEqual([{ a: 1 }]);
103
+ });
104
+
105
+ it("appendJsonlLine is immutable and appends a single line", () => {
106
+ const prev = '{"a":1}\n';
107
+ const next = appendJsonlLine(prev, { b: 2 });
108
+ expect(next).toBe('{"a":1}\n{"b":2}\n');
109
+ expect(prev).toBe('{"a":1}\n');
110
+ expect(appendJsonlLine("", { x: 1 })).toBe('{"x":1}\n');
111
+ expect(appendJsonlLine('{"a":1}', { b: 2 })).toBe('{"a":1}\n{"b":2}\n');
112
+ });
113
+
114
+ it("emptyProjectMetadata is well-formed", () => {
115
+ const m = emptyProjectMetadata("proj-a", "Proj A");
116
+ expect(m.projectId).toBe("proj-a");
117
+ expect(m.conversations).toEqual([]);
118
+ expect(m.deployments).toEqual([]);
119
+ });
120
+ });
121
+
122
+ describe("ProjectRepo metadata", () => {
123
+ let host: FakeGitHost;
124
+ let repo: ProjectRepo;
125
+ beforeEach(() => {
126
+ host = new FakeGitHost();
127
+ repo = makeRepo(host);
128
+ });
129
+
130
+ it("reads missing metadata as empty without throwing", async () => {
131
+ const m = await repo.readMetadata(T, REPO);
132
+ expect(m.conversations).toEqual([]);
133
+ expect(m.deployments).toEqual([]);
134
+ });
135
+
136
+ it("throws typed error on malformed JSON metadata", async () => {
137
+ await host.writeCommit(REPO, "main", { [METADATA_PATH]: "{not json" }, "x");
138
+ await expect(repo.readMetadata(T, REPO)).rejects.toBeInstanceOf(ProjectRepoError);
139
+ });
140
+
141
+ it("write -> read round-trips", async () => {
142
+ const meta = emptyProjectMetadata("proj-a", "Proj A");
143
+ const input = { ...meta, productionDomain: "proj-a.example.com" };
144
+ const ref = await repo.writeMetadata(T, REPO, input);
145
+ expect(ref.sha).toBeTruthy();
146
+ const back = await repo.readMetadata(T, REPO);
147
+ expect(back.productionDomain).toBe("proj-a.example.com");
148
+ // input not mutated
149
+ expect(input.conversations).toEqual([]);
150
+ });
151
+ });
152
+
153
+ describe("ProjectRepo conversations", () => {
154
+ let host: FakeGitHost;
155
+ let repo: ProjectRepo;
156
+ beforeEach(() => {
157
+ host = new FakeGitHost();
158
+ repo = makeRepo(host);
159
+ });
160
+
161
+ it("appends one commit per call and accumulates", async () => {
162
+ await repo.appendConversationMessage(T, REPO, "c1", { role: "user", text: "hi" });
163
+ expect(host.writeCount).toBe(1);
164
+ await repo.appendConversationMessage(T, REPO, "c1", { role: "assistant", text: "yo" });
165
+ expect(host.writeCount).toBe(2);
166
+ const msgs = await repo.readConversationMessages(T, REPO, "c1");
167
+ expect(msgs).toEqual([
168
+ { role: "user", text: "hi" },
169
+ { role: "assistant", text: "yo" },
170
+ ]);
171
+ });
172
+
173
+ it("readConversationMessages tolerates blank / missing file", async () => {
174
+ expect(await repo.readConversationMessages(T, REPO, "none")).toEqual([]);
175
+ await host.writeCommit(REPO, "main", { [`${CONVERSATIONS_DIR}/c2.jsonl`]: "\n\n" }, "blank");
176
+ expect(await repo.readConversationMessages(T, REPO, "c2")).toEqual([]);
177
+ });
178
+ });
179
+
180
+ describe("ProjectRepo history & restore", () => {
181
+ let host: FakeGitHost;
182
+ let repo: ProjectRepo;
183
+ beforeEach(() => {
184
+ host = new FakeGitHost();
185
+ repo = makeRepo(host);
186
+ });
187
+
188
+ it("restoreAt reads metadata at a specific sha", async () => {
189
+ const r1 = await repo.writeMetadata(T, REPO, {
190
+ ...emptyProjectMetadata("proj-a", "v1"),
191
+ });
192
+ await repo.writeMetadata(T, REPO, {
193
+ ...emptyProjectMetadata("proj-a", "v2"),
194
+ });
195
+ const restored = await repo.restoreAt(T, REPO, r1.sha);
196
+ expect(restored.metadata.name).toBe("v1");
197
+ const head = await repo.readMetadata(T, REPO);
198
+ expect(head.name).toBe("v2");
199
+ });
200
+
201
+ it("historyFromCommits delegates to the port", async () => {
202
+ await repo.writeMetadata(T, REPO, emptyProjectMetadata("proj-a", "v1"));
203
+ await repo.writeMetadata(T, REPO, emptyProjectMetadata("proj-a", "v2"));
204
+ const hist = await repo.historyFromCommits(T, REPO, 1);
205
+ expect(hist).toHaveLength(1);
206
+ });
207
+ });
208
+
209
+ describe("multi-tenant fail-closed", () => {
210
+ let host: FakeGitHost;
211
+ beforeEach(() => {
212
+ host = new FakeGitHost();
213
+ });
214
+
215
+ it("empty tenant throws", async () => {
216
+ const repo = makeRepo(host);
217
+ await expect(repo.readMetadata("", REPO)).rejects.toBeInstanceOf(ProjectRepoError);
218
+ await expect(repo.readMetadata(" ", REPO)).rejects.toBeInstanceOf(ProjectRepoError);
219
+ });
220
+
221
+ it("denies cross-tenant repo access (ownsRepo default-deny)", async () => {
222
+ const repo = makeRepo(host, (tenant, repoId) => repoId.startsWith(`${tenant}/`));
223
+ await expect(repo.readMetadata("tenant-2", "tenant-1/proj-a")).rejects.toBeInstanceOf(
224
+ ProjectRepoError,
225
+ );
226
+ });
227
+
228
+ it("default ownsRepo is deny when not provided", async () => {
229
+ const repo = new ProjectRepo({ host });
230
+ await expect(repo.readMetadata(T, REPO)).rejects.toBeInstanceOf(ProjectRepoError);
231
+ });
232
+ });
@@ -0,0 +1,311 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Git-backed project repository model.
5
+ *
6
+ * Faithful re-expression of the "every project is a git repo" persistence
7
+ * model: a project's metadata and conversation logs live AS committed files
8
+ * inside its repository. The commit graph IS the history — there is no
9
+ * in-memory snapshot ring (that is `workbench.ts`'s concern). The actual
10
+ * provider-specific git implementation is injected via `GitHostPort`; this
11
+ * module never performs git operations itself.
12
+ */
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Domain types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export interface ConversationSummary {
19
+ id: string;
20
+ title: string;
21
+ createdAt: string;
22
+ updatedAt: string;
23
+ }
24
+
25
+ export type DeploymentState = "idle" | "deploying" | "live" | "failed";
26
+
27
+ export interface DeploymentSummary {
28
+ commitSha: string;
29
+ commitMessage: string;
30
+ commitDate: string;
31
+ domain: string;
32
+ url: string;
33
+ deploymentId: string | null;
34
+ state: DeploymentState;
35
+ }
36
+
37
+ export interface ProjectMetadata {
38
+ projectId: string;
39
+ name: string;
40
+ conversations: ConversationSummary[];
41
+ deployments: DeploymentSummary[];
42
+ productionDomain?: string | undefined;
43
+ productionDeploymentId?: string | undefined;
44
+ }
45
+
46
+ export interface CommitRef {
47
+ sha: string;
48
+ message: string;
49
+ date: string;
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Injected git-host port (provider-specific git impl is NOT ported here)
54
+ // ---------------------------------------------------------------------------
55
+
56
+ export interface GitHostPort {
57
+ getDefaultBranch(repoId: string): Promise<string>;
58
+ readFile(repoId: string, path: string, ref?: string | undefined): Promise<string | null>;
59
+ writeCommit(
60
+ repoId: string,
61
+ branch: string,
62
+ files: Record<string, string>,
63
+ message: string,
64
+ ): Promise<CommitRef>;
65
+ listCommits(repoId: string, branch: string, limit?: number | undefined): Promise<CommitRef[]>;
66
+ }
67
+
68
+ /** Default-deny ownership predicate: cross-tenant access impossible unless wired. */
69
+ export type OwnsRepoPredicate = (tenantId: string, repoId: string) => boolean | Promise<boolean>;
70
+
71
+ export interface ProjectRepoDeps {
72
+ host: GitHostPort;
73
+ ownsRepo?: OwnsRepoPredicate | undefined;
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Typed error
78
+ // ---------------------------------------------------------------------------
79
+
80
+ export type ProjectRepoErrorCode = "tenant_required" | "tenant_denied" | "metadata_malformed";
81
+
82
+ export class ProjectRepoError extends Error {
83
+ public readonly code: ProjectRepoErrorCode;
84
+ constructor(code: ProjectRepoErrorCode, message: string) {
85
+ super(message);
86
+ this.name = "ProjectRepoError";
87
+ this.code = code;
88
+ }
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Constants
93
+ // ---------------------------------------------------------------------------
94
+
95
+ export const METADATA_PATH = "metadata.json";
96
+ export const CONVERSATIONS_DIR = "conversations";
97
+
98
+ const conversationPath = (conversationId: string): string =>
99
+ `${CONVERSATIONS_DIR}/${conversationId}.jsonl`;
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Pure helpers
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /** Parse JSONL content; tolerant of blank lines and trailing newline. */
106
+ export function parseJsonl(content: string): unknown[] {
107
+ return content
108
+ .split("\n")
109
+ .map((line) => line.trim())
110
+ .filter((line) => line.length > 0)
111
+ .map((line) => JSON.parse(line) as unknown);
112
+ }
113
+
114
+ /** Append one JSON line to existing JSONL content (immutable; never mutates input). */
115
+ export function appendJsonlLine(prev: string, value: unknown): string {
116
+ const base = prev.length > 0 && !prev.endsWith("\n") ? `${prev}\n` : prev;
117
+ return `${base}${JSON.stringify(value)}\n`;
118
+ }
119
+
120
+ /** Well-formed empty metadata — used when a project repo has no metadata yet. */
121
+ export function emptyProjectMetadata(projectId: string, name: string): ProjectMetadata {
122
+ return {
123
+ projectId,
124
+ name,
125
+ conversations: [],
126
+ deployments: [],
127
+ };
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Zod schema (boundary validation)
132
+ // ---------------------------------------------------------------------------
133
+
134
+ const conversationSummarySchema = z.object({
135
+ id: z.string(),
136
+ title: z.string(),
137
+ createdAt: z.string(),
138
+ updatedAt: z.string(),
139
+ });
140
+
141
+ const deploymentSummarySchema = z.object({
142
+ commitSha: z.string(),
143
+ commitMessage: z.string(),
144
+ commitDate: z.string(),
145
+ domain: z.string(),
146
+ url: z.string(),
147
+ deploymentId: z.string().nullable(),
148
+ state: z.enum(["idle", "deploying", "live", "failed"]),
149
+ });
150
+
151
+ const projectMetadataSchema = z.object({
152
+ projectId: z.string(),
153
+ name: z.string(),
154
+ conversations: z.array(conversationSummarySchema),
155
+ deployments: z.array(deploymentSummarySchema),
156
+ productionDomain: z.string().optional(),
157
+ productionDeploymentId: z.string().optional(),
158
+ });
159
+
160
+ function normalizeMetadata(meta: ProjectMetadata): ProjectMetadata {
161
+ const parsed = projectMetadataSchema.parse(meta);
162
+ const out: ProjectMetadata = {
163
+ projectId: parsed.projectId,
164
+ name: parsed.name,
165
+ conversations: parsed.conversations,
166
+ deployments: parsed.deployments,
167
+ };
168
+ if (parsed.productionDomain !== undefined) {
169
+ out.productionDomain = parsed.productionDomain;
170
+ }
171
+ if (parsed.productionDeploymentId !== undefined) {
172
+ out.productionDeploymentId = parsed.productionDeploymentId;
173
+ }
174
+ return out;
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // ProjectRepo
179
+ // ---------------------------------------------------------------------------
180
+
181
+ export class ProjectRepo {
182
+ private readonly host: GitHostPort;
183
+ private readonly ownsRepo: OwnsRepoPredicate;
184
+
185
+ constructor(deps: ProjectRepoDeps) {
186
+ this.host = deps.host;
187
+ // Default-deny: if no ownership predicate is wired, all access is denied.
188
+ this.ownsRepo = deps.ownsRepo ?? (() => false);
189
+ }
190
+
191
+ private async authorize(tenantId: string, repoId: string): Promise<void> {
192
+ if (typeof tenantId !== "string" || tenantId.trim().length === 0) {
193
+ throw new ProjectRepoError("tenant_required", "tenantId is required");
194
+ }
195
+ const owns = await this.ownsRepo(tenantId, repoId);
196
+ if (!owns) {
197
+ throw new ProjectRepoError(
198
+ "tenant_denied",
199
+ `tenant ${tenantId} may not access repo ${repoId}`,
200
+ );
201
+ }
202
+ }
203
+
204
+ private async defaultBranch(repoId: string): Promise<string> {
205
+ return this.host.getDefaultBranch(repoId);
206
+ }
207
+
208
+ async readMetadata(tenantId: string, repoId: string): Promise<ProjectMetadata> {
209
+ await this.authorize(tenantId, repoId);
210
+ const raw = await this.host.readFile(repoId, METADATA_PATH);
211
+ if (raw === null) {
212
+ return emptyProjectMetadata("", "");
213
+ }
214
+ let parsedJson: unknown;
215
+ try {
216
+ parsedJson = JSON.parse(raw);
217
+ } catch {
218
+ throw new ProjectRepoError("metadata_malformed", `${METADATA_PATH} is not valid JSON`);
219
+ }
220
+ const result = projectMetadataSchema.safeParse(parsedJson);
221
+ if (!result.success) {
222
+ throw new ProjectRepoError(
223
+ "metadata_malformed",
224
+ `${METADATA_PATH} does not match the project metadata schema`,
225
+ );
226
+ }
227
+ return normalizeMetadata(result.data);
228
+ }
229
+
230
+ async writeMetadata(tenantId: string, repoId: string, meta: ProjectMetadata): Promise<CommitRef> {
231
+ await this.authorize(tenantId, repoId);
232
+ const normalized = normalizeMetadata(meta);
233
+ const branch = await this.defaultBranch(repoId);
234
+ return this.host.writeCommit(
235
+ repoId,
236
+ branch,
237
+ { [METADATA_PATH]: `${JSON.stringify(normalized, null, 2)}\n` },
238
+ "chore: update project metadata",
239
+ );
240
+ }
241
+
242
+ async appendConversationMessage(
243
+ tenantId: string,
244
+ repoId: string,
245
+ conversationId: string,
246
+ message: unknown,
247
+ ): Promise<CommitRef> {
248
+ await this.authorize(tenantId, repoId);
249
+ const path = conversationPath(conversationId);
250
+ const existing = (await this.host.readFile(repoId, path)) ?? "";
251
+ const next = appendJsonlLine(existing, message);
252
+ const branch = await this.defaultBranch(repoId);
253
+ // One commit per call = one commit per turn.
254
+ return this.host.writeCommit(
255
+ repoId,
256
+ branch,
257
+ { [path]: next },
258
+ `chore: append message to ${conversationId}`,
259
+ );
260
+ }
261
+
262
+ async readConversationMessages(
263
+ tenantId: string,
264
+ repoId: string,
265
+ conversationId: string,
266
+ ): Promise<unknown[]> {
267
+ await this.authorize(tenantId, repoId);
268
+ const raw = await this.host.readFile(repoId, conversationPath(conversationId));
269
+ if (raw === null) return [];
270
+ return parseJsonl(raw);
271
+ }
272
+
273
+ async restoreAt(
274
+ tenantId: string,
275
+ repoId: string,
276
+ sha: string,
277
+ ): Promise<{ metadata: ProjectMetadata }> {
278
+ await this.authorize(tenantId, repoId);
279
+ const raw = await this.host.readFile(repoId, METADATA_PATH, sha);
280
+ if (raw === null) {
281
+ return { metadata: emptyProjectMetadata("", "") };
282
+ }
283
+ let parsedJson: unknown;
284
+ try {
285
+ parsedJson = JSON.parse(raw);
286
+ } catch {
287
+ throw new ProjectRepoError(
288
+ "metadata_malformed",
289
+ `${METADATA_PATH} at ${sha} is not valid JSON`,
290
+ );
291
+ }
292
+ const result = projectMetadataSchema.safeParse(parsedJson);
293
+ if (!result.success) {
294
+ throw new ProjectRepoError(
295
+ "metadata_malformed",
296
+ `${METADATA_PATH} at ${sha} does not match the project metadata schema`,
297
+ );
298
+ }
299
+ return { metadata: normalizeMetadata(result.data) };
300
+ }
301
+
302
+ async historyFromCommits(
303
+ tenantId: string,
304
+ repoId: string,
305
+ limit?: number | undefined,
306
+ ): Promise<CommitRef[]> {
307
+ await this.authorize(tenantId, repoId);
308
+ const branch = await this.defaultBranch(repoId);
309
+ return this.host.listCommits(repoId, branch, limit);
310
+ }
311
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * App-server protocol contract (WRAP — capability #5).
3
+ *
4
+ * Faithful re-expression of the upstream JSON-RPC method registry, the
5
+ * serialization-scope concurrency model, server-initiated approval requests,
6
+ * and the notification event stream.
7
+ *
8
+ * Multi-tenant adaptation (non-negotiable): every serialization scope is
9
+ * additionally keyed by `tenantId`. Upstream `Thread { thread_id }` becomes
10
+ * `{ tenantId, threadId }` — natural per-tenant ordering + isolation for free.
11
+ * This is the only coupling seam to an optional decoupled kernel sidecar
12
+ * (Track B); transport (WS/SSE/stdio) is pluggable and not defined here.
13
+ */
14
+
15
+ import { z } from "zod";
16
+
17
+ // ── Serialization scope (concurrency control) ────────────────────────────────
18
+
19
+ /**
20
+ * Requests sharing a scope are serialized; distinct scopes run in parallel.
21
+ * `tenantId` is mandatory on every scope — cross-tenant requests can never
22
+ * share a scope.
23
+ */
24
+ export type SerializationScope =
25
+ | { readonly tenantId: string; readonly kind: "none" }
26
+ | { readonly tenantId: string; readonly kind: "global"; readonly key: string }
27
+ | { readonly tenantId: string; readonly kind: "global_shared_read"; readonly key: string }
28
+ | { readonly tenantId: string; readonly kind: "thread"; readonly threadId: string }
29
+ | { readonly tenantId: string; readonly kind: "command_exec"; readonly processId: string }
30
+ | { readonly tenantId: string; readonly kind: "mcp_oauth"; readonly server: string };
31
+
32
+ /** Stable string key used to coalesce requests into the same serial lane. */
33
+ export function scopeKey(scope: SerializationScope): string {
34
+ switch (scope.kind) {
35
+ case "none":
36
+ return `t:${scope.tenantId}|none:${cryptoRandom()}`;
37
+ case "global":
38
+ case "global_shared_read":
39
+ return `t:${scope.tenantId}|${scope.kind}:${scope.key}`;
40
+ case "thread":
41
+ return `t:${scope.tenantId}|thread:${scope.threadId}`;
42
+ case "command_exec":
43
+ return `t:${scope.tenantId}|cmd:${scope.processId}`;
44
+ case "mcp_oauth":
45
+ return `t:${scope.tenantId}|mcpoauth:${scope.server}`;
46
+ }
47
+ }
48
+
49
+ function cryptoRandom(): string {
50
+ return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
51
+ }
52
+
53
+ // ── Client -> server method registry ─────────────────────────────────────────
54
+
55
+ /** How a method's scope is derived from its params. */
56
+ export type ScopeRule =
57
+ | { readonly kind: "none" }
58
+ | { readonly kind: "global"; readonly key: string }
59
+ | { readonly kind: "thread" };
60
+
61
+ export interface MethodSpec {
62
+ /** Namespaced `domain/verb` wire name. */
63
+ readonly method: string;
64
+ readonly scope: ScopeRule;
65
+ readonly experimental?: boolean;
66
+ }
67
+
68
+ /**
69
+ * The faithful subset of the upstream registry relevant to a multi-tenant web
70
+ * product. `thread`-scoped methods derive `{ tenantId, threadId }`.
71
+ */
72
+ export const METHOD_REGISTRY = {
73
+ initialize: { method: "initialize", scope: { kind: "none" } },
74
+ threadStart: { method: "thread/start", scope: { kind: "none" } },
75
+ threadResume: { method: "thread/resume", scope: { kind: "thread" } },
76
+ threadFork: { method: "thread/fork", scope: { kind: "thread" } },
77
+ threadArchive: { method: "thread/archive", scope: { kind: "thread" } },
78
+ threadCompactStart: { method: "thread/compact/start", scope: { kind: "thread" } },
79
+ threadRollback: { method: "thread/rollback", scope: { kind: "thread" } },
80
+ turnStart: { method: "turn/start", scope: { kind: "thread" } },
81
+ turnSteer: { method: "turn/steer", scope: { kind: "thread" } },
82
+ turnInterrupt: { method: "turn/interrupt", scope: { kind: "thread" } },
83
+ modelList: { method: "model/list", scope: { kind: "none" } },
84
+ } as const satisfies Record<string, MethodSpec>;
85
+
86
+ export type MethodName = (typeof METHOD_REGISTRY)[keyof typeof METHOD_REGISTRY]["method"];
87
+
88
+ /** Resolve a method's runtime scope from tenant + params. */
89
+ export function resolveScope(
90
+ spec: MethodSpec,
91
+ tenantId: string,
92
+ params: { threadId?: string },
93
+ ): SerializationScope {
94
+ switch (spec.scope.kind) {
95
+ case "none":
96
+ return { tenantId, kind: "none" };
97
+ case "global":
98
+ return { tenantId, kind: "global", key: spec.scope.key };
99
+ case "thread": {
100
+ if (!params.threadId) {
101
+ throw new Error(`Method ${spec.method} requires a threadId for scoping`);
102
+ }
103
+ return { tenantId, kind: "thread", threadId: params.threadId };
104
+ }
105
+ }
106
+ }
107
+
108
+ // ── Server -> client requests (approval / elicitation) ───────────────────────
109
+
110
+ /** Approvals are server-initiated RPC, not side channels. */
111
+ export type ServerRequest =
112
+ | {
113
+ readonly type: "command_execution.request_approval";
114
+ readonly requestId: string;
115
+ readonly command: string;
116
+ }
117
+ | {
118
+ readonly type: "file_change.request_approval";
119
+ readonly requestId: string;
120
+ readonly paths: readonly string[];
121
+ }
122
+ | {
123
+ readonly type: "permissions.request_approval";
124
+ readonly requestId: string;
125
+ readonly summary: string;
126
+ }
127
+ | {
128
+ readonly type: "mcp.elicitation";
129
+ readonly requestId: string;
130
+ readonly server: string;
131
+ readonly schema: unknown;
132
+ };
133
+
134
+ // ── Server -> client notifications (event stream) ────────────────────────────
135
+
136
+ export const NOTIFICATIONS = [
137
+ "thread/started",
138
+ "turn/started",
139
+ "turn/completed",
140
+ "turn/failed",
141
+ "item/started",
142
+ "item/updated",
143
+ "item/completed",
144
+ "model/rerouted",
145
+ "thread/compacted",
146
+ ] as const;
147
+ export type NotificationName = (typeof NOTIFICATIONS)[number];
148
+
149
+ // ── Boundary validation for the universal envelope ───────────────────────────
150
+
151
+ export const requestEnvelopeSchema = z.object({
152
+ jsonrpc: z.literal("2.0"),
153
+ id: z.union([z.string(), z.number()]),
154
+ method: z.string(),
155
+ /** tenantId is mandatory at the envelope boundary — fail closed otherwise. */
156
+ tenantId: z.string().min(1),
157
+ params: z.unknown().optional(),
158
+ });
159
+ export type RequestEnvelope = z.infer<typeof requestEnvelopeSchema>;