@ontrails/trails 1.0.0-beta.4 → 1.0.0-beta.41

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 (165) hide show
  1. package/CHANGELOG.md +1104 -4
  2. package/README.md +27 -0
  3. package/package.json +31 -7
  4. package/src/app.ts +147 -3
  5. package/src/cli.ts +345 -11
  6. package/src/completions.ts +240 -0
  7. package/src/lifecycle-source-io.ts +33 -0
  8. package/src/load-app-mirror.ts +202 -0
  9. package/src/local-state-io.ts +173 -0
  10. package/src/mcp-app.ts +42 -0
  11. package/src/mcp-options.ts +91 -0
  12. package/src/mcp.ts +8 -0
  13. package/src/project-writes.ts +377 -0
  14. package/src/regrade/config.ts +152 -0
  15. package/src/regrade/history.ts +430 -0
  16. package/src/regrade/live-api-preserve.ts +8 -0
  17. package/src/regrade/plan-artifact.ts +285 -0
  18. package/src/release/bindings.ts +39 -0
  19. package/src/release/check.ts +844 -0
  20. package/src/release/config.ts +63 -0
  21. package/src/release/contract-facts.ts +425 -0
  22. package/src/release/index.ts +142 -0
  23. package/src/release/lock-roundtrip-smoke.ts +230 -0
  24. package/src/release/native-bun-publish.ts +651 -0
  25. package/src/release/native-bun-registry.ts +765 -0
  26. package/src/release/notes-cli.ts +171 -0
  27. package/src/release/notes.ts +390 -0
  28. package/src/release/pack-coherence.ts +455 -0
  29. package/src/release/packed-artifacts-smoke.ts +236 -0
  30. package/src/release/policy.ts +1687 -0
  31. package/src/release/semver.ts +104 -0
  32. package/src/release/smoke.ts +56 -0
  33. package/src/release/wayfinder-dogfood-smoke.ts +760 -0
  34. package/src/retired-topo-command.ts +36 -0
  35. package/src/run-adapter-check.ts +76 -0
  36. package/src/run-collision.ts +126 -0
  37. package/src/run-completions-install.ts +179 -0
  38. package/src/run-example.ts +149 -0
  39. package/src/run-examples.ts +148 -0
  40. package/src/run-quiet.ts +75 -0
  41. package/src/run-release-check.ts +74 -0
  42. package/src/run-schema.ts +74 -0
  43. package/src/run-trace.ts +273 -0
  44. package/src/run-warden.ts +39 -0
  45. package/src/run-watch.ts +432 -0
  46. package/src/run-wayfind-outline.ts +170 -0
  47. package/src/scaffold-version-sync.ts +183 -0
  48. package/src/scaffold-versions.generated.ts +12 -0
  49. package/src/trails/adapter-check.ts +244 -0
  50. package/src/trails/add-surface.ts +99 -45
  51. package/src/trails/add-trail.ts +84 -37
  52. package/src/trails/add-verify.ts +100 -30
  53. package/src/trails/compile.ts +58 -0
  54. package/src/trails/completions-complete.ts +165 -0
  55. package/src/trails/completions.ts +47 -0
  56. package/src/trails/create-adapter.ts +785 -0
  57. package/src/trails/create-scaffold.ts +401 -106
  58. package/src/trails/create-versions.ts +62 -0
  59. package/src/trails/create.ts +186 -72
  60. package/src/trails/deprecate.ts +59 -0
  61. package/src/trails/dev-clean.ts +82 -0
  62. package/src/trails/dev-reset.ts +50 -0
  63. package/src/trails/dev-stats.ts +72 -0
  64. package/src/trails/dev-support.ts +360 -0
  65. package/src/trails/doctor.ts +77 -0
  66. package/src/trails/draft-promote.ts +949 -0
  67. package/src/trails/guide.ts +70 -68
  68. package/src/trails/load-app.ts +1123 -15
  69. package/src/trails/operator-context.ts +66 -0
  70. package/src/trails/project.ts +17 -3
  71. package/src/trails/regrade.ts +3091 -0
  72. package/src/trails/release-check.ts +105 -0
  73. package/src/trails/release-smoke.ts +49 -0
  74. package/src/trails/revise.ts +53 -0
  75. package/src/trails/root-dir.ts +21 -0
  76. package/src/trails/run-example.ts +475 -0
  77. package/src/trails/run-examples.ts +129 -0
  78. package/src/trails/run.ts +434 -0
  79. package/src/trails/scaffold-json.ts +58 -0
  80. package/src/trails/survey.ts +877 -214
  81. package/src/trails/topo-activation.ts +14 -0
  82. package/src/trails/topo-constants.ts +2 -0
  83. package/src/trails/topo-history.ts +47 -0
  84. package/src/trails/topo-output-schemas.ts +259 -0
  85. package/src/trails/topo-pin.ts +38 -0
  86. package/src/trails/topo-read-support.ts +368 -0
  87. package/src/trails/topo-reports.ts +809 -0
  88. package/src/trails/topo-store-support.ts +325 -0
  89. package/src/trails/topo-support.ts +220 -0
  90. package/src/trails/topo-unpin.ts +61 -0
  91. package/src/trails/topo.ts +92 -0
  92. package/src/trails/validate.ts +27 -0
  93. package/src/trails/version-lifecycle-support.ts +936 -0
  94. package/src/trails/warden-guide.ts +134 -0
  95. package/src/trails/warden.ts +198 -58
  96. package/src/trails/wayfind-outline.ts +876 -0
  97. package/src/trails/wayfind.ts +1053 -0
  98. package/src/versions.ts +31 -0
  99. package/.turbo/turbo-build.log +0 -1
  100. package/.turbo/turbo-lint.log +0 -3
  101. package/.turbo/turbo-typecheck.log +0 -1
  102. package/__tests__/examples.test.ts +0 -6
  103. package/dist/bin/trails.d.ts +0 -3
  104. package/dist/bin/trails.d.ts.map +0 -1
  105. package/dist/bin/trails.js +0 -4
  106. package/dist/bin/trails.js.map +0 -1
  107. package/dist/src/app.d.ts +0 -2
  108. package/dist/src/app.d.ts.map +0 -1
  109. package/dist/src/app.js +0 -11
  110. package/dist/src/app.js.map +0 -1
  111. package/dist/src/clack.d.ts +0 -9
  112. package/dist/src/clack.d.ts.map +0 -1
  113. package/dist/src/clack.js +0 -84
  114. package/dist/src/clack.js.map +0 -1
  115. package/dist/src/cli.d.ts +0 -2
  116. package/dist/src/cli.d.ts.map +0 -1
  117. package/dist/src/cli.js +0 -13
  118. package/dist/src/cli.js.map +0 -1
  119. package/dist/src/trails/add-surface.d.ts +0 -13
  120. package/dist/src/trails/add-surface.d.ts.map +0 -1
  121. package/dist/src/trails/add-surface.js +0 -88
  122. package/dist/src/trails/add-surface.js.map +0 -1
  123. package/dist/src/trails/add-trail.d.ts +0 -10
  124. package/dist/src/trails/add-trail.d.ts.map +0 -1
  125. package/dist/src/trails/add-trail.js +0 -77
  126. package/dist/src/trails/add-trail.js.map +0 -1
  127. package/dist/src/trails/add-verify.d.ts +0 -10
  128. package/dist/src/trails/add-verify.d.ts.map +0 -1
  129. package/dist/src/trails/add-verify.js +0 -67
  130. package/dist/src/trails/add-verify.js.map +0 -1
  131. package/dist/src/trails/create-scaffold.d.ts +0 -15
  132. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  133. package/dist/src/trails/create-scaffold.js +0 -288
  134. package/dist/src/trails/create-scaffold.js.map +0 -1
  135. package/dist/src/trails/create.d.ts +0 -22
  136. package/dist/src/trails/create.d.ts.map +0 -1
  137. package/dist/src/trails/create.js +0 -121
  138. package/dist/src/trails/create.js.map +0 -1
  139. package/dist/src/trails/guide.d.ts +0 -11
  140. package/dist/src/trails/guide.d.ts.map +0 -1
  141. package/dist/src/trails/guide.js +0 -80
  142. package/dist/src/trails/guide.js.map +0 -1
  143. package/dist/src/trails/load-app.d.ts +0 -4
  144. package/dist/src/trails/load-app.d.ts.map +0 -1
  145. package/dist/src/trails/load-app.js +0 -24
  146. package/dist/src/trails/load-app.js.map +0 -1
  147. package/dist/src/trails/project.d.ts +0 -8
  148. package/dist/src/trails/project.d.ts.map +0 -1
  149. package/dist/src/trails/project.js +0 -43
  150. package/dist/src/trails/project.js.map +0 -1
  151. package/dist/src/trails/survey.d.ts +0 -31
  152. package/dist/src/trails/survey.d.ts.map +0 -1
  153. package/dist/src/trails/survey.js +0 -221
  154. package/dist/src/trails/survey.js.map +0 -1
  155. package/dist/src/trails/warden.d.ts +0 -19
  156. package/dist/src/trails/warden.d.ts.map +0 -1
  157. package/dist/src/trails/warden.js +0 -88
  158. package/dist/src/trails/warden.js.map +0 -1
  159. package/dist/tsconfig.tsbuildinfo +0 -1
  160. package/src/__tests__/create.test.ts +0 -349
  161. package/src/__tests__/guide.test.ts +0 -91
  162. package/src/__tests__/load-app.test.ts +0 -15
  163. package/src/__tests__/survey.test.ts +0 -159
  164. package/src/__tests__/warden.test.ts +0 -74
  165. package/tsconfig.json +0 -9
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Stored-export pipeline for topo persistence.
3
+ *
4
+ * Extracted from topo-support.ts to isolate store persistence concerns,
5
+ * keeping module boundaries clean.
6
+ */
7
+
8
+ import { Database } from 'bun:sqlite';
9
+
10
+ import type { Topo } from '@ontrails/core';
11
+ import {
12
+ ConflictError,
13
+ deriveTrailsDir,
14
+ InternalError,
15
+ openWriteTrailsDb,
16
+ Result,
17
+ TimeoutError,
18
+ } from '@ontrails/core';
19
+ import type {
20
+ LockManifest,
21
+ TopoGraph,
22
+ TopoGraphOverlayRegistration,
23
+ TopoSnapshot,
24
+ TrailsLock,
25
+ } from '@ontrails/topography';
26
+ import type { StoredTopoExport } from '@ontrails/topography/backend-support';
27
+ import {
28
+ annotateTopoGraphForces,
29
+ carryForwardTopoGraphForces,
30
+ deriveSourceFingerprint,
31
+ deriveTopoGraphDiff,
32
+ deriveTopoGraphHash,
33
+ isTopoArtifactRegenerationError,
34
+ readTopoGraph,
35
+ TRAILS_LOCK_SCHEMA_VERSION,
36
+ writeTrailsLock,
37
+ } from '@ontrails/topography';
38
+ import {
39
+ createStoredTopoSnapshot,
40
+ getStoredTopoExport,
41
+ } from '@ontrails/topography/backend-support';
42
+
43
+ import { removeRootRelativeFileIfPresent } from '../local-state-io.js';
44
+
45
+ import type { TopoExportReport } from './topo-support.js';
46
+ import {
47
+ deriveRootDir,
48
+ deriveTopoCounts,
49
+ readGitState,
50
+ } from './topo-support.js';
51
+
52
+ type OverlaysOption = readonly TopoGraphOverlayRegistration[];
53
+
54
+ const persistAndReadStoredExport = (
55
+ app: Topo,
56
+ db: ReturnType<typeof openWriteTrailsDb>,
57
+ rootDir: string,
58
+ options?:
59
+ | {
60
+ readonly overlays?: OverlaysOption | undefined;
61
+ }
62
+ | undefined
63
+ ): Result<
64
+ { snapshot: TopoSnapshot; storedExport: StoredTopoExport },
65
+ Error
66
+ > => {
67
+ const snapshotResult = createStoredTopoSnapshot(db, app, {
68
+ overlays: options?.overlays,
69
+ sourceFingerprint: deriveSourceFingerprint(rootDir),
70
+ ...readGitState(rootDir),
71
+ ...deriveTopoCounts(app),
72
+ });
73
+ if (snapshotResult.isErr()) {
74
+ return snapshotResult;
75
+ }
76
+
77
+ const snapshot = snapshotResult.value;
78
+ const storedExport = getStoredTopoExport(db, snapshot.id);
79
+
80
+ if (storedExport === undefined) {
81
+ return Result.err(
82
+ new InternalError(
83
+ `Missing stored topo export for snapshot "${snapshot.id}"`
84
+ )
85
+ );
86
+ }
87
+
88
+ return Result.ok({
89
+ snapshot,
90
+ storedExport,
91
+ });
92
+ };
93
+
94
+ const asError = (error: unknown): Error =>
95
+ error instanceof Error ? error : new Error(String(error));
96
+
97
+ const readErrorCode = (error: unknown): string | undefined => {
98
+ if (typeof error !== 'object' || error === null || !('code' in error)) {
99
+ return undefined;
100
+ }
101
+ const { code } = error as { readonly code?: unknown };
102
+ return typeof code === 'string' ? code : undefined;
103
+ };
104
+
105
+ const isSqliteLockError = (error: unknown): boolean => {
106
+ const code = readErrorCode(error);
107
+ if (code === 'SQLITE_BUSY' || code === 'SQLITE_LOCKED') {
108
+ return true;
109
+ }
110
+ return asError(error).message.match(/database is (locked|busy)/i) !== null;
111
+ };
112
+
113
+ export const mapTopoExportError = (error: unknown): Error => {
114
+ if (!isSqliteLockError(error)) {
115
+ return error instanceof Error
116
+ ? error
117
+ : new InternalError('Unable to write topo artifacts');
118
+ }
119
+ return new TimeoutError(
120
+ 'Timed out waiting for the Trails topo store lock while compiling artifacts. Another topo write may be running; retry after it finishes.',
121
+ {
122
+ cause: asError(error),
123
+ context: {
124
+ operation: 'compile',
125
+ reason: 'sqlite-lock-contention',
126
+ resource: 'trails.db',
127
+ },
128
+ }
129
+ );
130
+ };
131
+
132
+ export const deriveCurrentTopoExport = (
133
+ app: Topo,
134
+ options?: {
135
+ readonly rootDir?: string;
136
+ readonly overlays?: OverlaysOption | undefined;
137
+ }
138
+ ): Result<StoredTopoExport, Error> => {
139
+ const rootDir = deriveRootDir(options?.rootDir);
140
+ const db = new Database(':memory:');
141
+
142
+ try {
143
+ const projected = persistAndReadStoredExport(app, db, rootDir, {
144
+ overlays: options?.overlays,
145
+ });
146
+ return projected.isErr()
147
+ ? projected
148
+ : Result.ok(projected.value.storedExport);
149
+ } finally {
150
+ db.close();
151
+ }
152
+ };
153
+
154
+ const readPreviousCommittedTopo = async (
155
+ rootDir: string
156
+ ): Promise<TopoGraph | null> => {
157
+ try {
158
+ const rootTopo = await readTopoGraph({ dir: rootDir });
159
+ return rootTopo ?? readTopoGraph({ dir: deriveTrailsDir({ rootDir }) });
160
+ } catch (error) {
161
+ if (isTopoArtifactRegenerationError(error)) {
162
+ return null;
163
+ }
164
+ throw error;
165
+ }
166
+ };
167
+
168
+ const prepareStoredExportArtifacts = async (
169
+ storedExport: StoredTopoExport,
170
+ rootDir: string,
171
+ options?: { readonly force?: boolean | undefined }
172
+ ): Promise<{
173
+ readonly hash: string;
174
+ readonly topoGraph: TopoGraph;
175
+ }> => {
176
+ const previousTopo = await readPreviousCommittedTopo(rootDir);
177
+ const nextTopo = JSON.parse(storedExport.topoGraphJson) as TopoGraph;
178
+ const diff =
179
+ previousTopo === null
180
+ ? undefined
181
+ : deriveTopoGraphDiff(previousTopo, nextTopo);
182
+ if (diff !== undefined && diff.breaking.length > 0 && !options?.force) {
183
+ throw new ConflictError(
184
+ `Topo contains ${diff.breaking.length} breaking change(s). Add a version entry, revert the change, or rerun with --force.`
185
+ );
186
+ }
187
+
188
+ const topoGraphBase =
189
+ previousTopo === null
190
+ ? nextTopo
191
+ : carryForwardTopoGraphForces(previousTopo, nextTopo);
192
+ const topoGraph =
193
+ diff === undefined || diff.breaking.length === 0
194
+ ? topoGraphBase
195
+ : annotateTopoGraphForces(topoGraphBase, diff.breaking);
196
+ const hash = deriveTopoGraphHash(topoGraph);
197
+
198
+ return {
199
+ hash,
200
+ topoGraph,
201
+ };
202
+ };
203
+
204
+ const LEGACY_COMMITTED_ARTIFACTS = [
205
+ '.trails/topo.lock',
206
+ '.trails/trails.lock',
207
+ ] as const;
208
+
209
+ const removeLegacyCommittedArtifacts = (
210
+ rootDir: string
211
+ ): Result<void, Error> => {
212
+ for (const relativePath of LEGACY_COMMITTED_ARTIFACTS) {
213
+ const removed = removeRootRelativeFileIfPresent(rootDir, relativePath);
214
+ if (removed.isErr()) {
215
+ return removed;
216
+ }
217
+ }
218
+
219
+ return Result.ok();
220
+ };
221
+
222
+ const writeStoredExportArtifacts = async (
223
+ storedExport: StoredTopoExport,
224
+ rootDir: string,
225
+ options?: { readonly force?: boolean | undefined }
226
+ ): Promise<Pick<TopoExportReport, 'hash' | 'lockPath'>> => {
227
+ const prepared = await prepareStoredExportArtifacts(
228
+ storedExport,
229
+ rootDir,
230
+ options
231
+ );
232
+
233
+ const lockManifest = JSON.parse(
234
+ storedExport.lockManifestJson
235
+ ) as LockManifest;
236
+ // Omit wallclock provenance from the committed artifact so recompiling the
237
+ // same sources yields byte-identical lock files; the hash never covered it.
238
+ const { generatedAt: _generatedAt, ...committedTopoGraph } =
239
+ prepared.topoGraph;
240
+ const lockPath = await writeTrailsLock(
241
+ {
242
+ scope: lockManifest.scope,
243
+ summary: {
244
+ entities: prepared.topoGraph.entries.filter(
245
+ (entry) => entry.kind === 'entity'
246
+ ).length,
247
+ resources: prepared.topoGraph.entries.filter(
248
+ (entry) => entry.kind === 'resource'
249
+ ).length,
250
+ signals: prepared.topoGraph.entries.filter(
251
+ (entry) => entry.kind === 'signal'
252
+ ).length,
253
+ trails: prepared.topoGraph.entries.filter(
254
+ (entry) => entry.kind === 'trail'
255
+ ).length,
256
+ },
257
+ topoGraph: committedTopoGraph,
258
+ topoGraphHash: prepared.hash,
259
+ version: TRAILS_LOCK_SCHEMA_VERSION,
260
+ } as TrailsLock,
261
+ { dir: rootDir }
262
+ );
263
+ const removedLegacyArtifacts = removeLegacyCommittedArtifacts(rootDir);
264
+ if (removedLegacyArtifacts.isErr()) {
265
+ throw removedLegacyArtifacts.error;
266
+ }
267
+
268
+ return {
269
+ hash: prepared.hash,
270
+ lockPath,
271
+ };
272
+ };
273
+
274
+ export const exportCurrentTopo = async (
275
+ app: Topo,
276
+ options?: {
277
+ readonly force?: boolean | undefined;
278
+ readonly rootDir?: string;
279
+ readonly overlays?: OverlaysOption | undefined;
280
+ }
281
+ ): Promise<Result<TopoExportReport, Error>> => {
282
+ const rootDir = deriveRootDir(options?.rootDir);
283
+ let db: ReturnType<typeof openWriteTrailsDb> | undefined;
284
+
285
+ try {
286
+ const candidate = deriveCurrentTopoExport(app, {
287
+ overlays: options?.overlays,
288
+ rootDir,
289
+ });
290
+ if (candidate.isErr()) {
291
+ return candidate;
292
+ }
293
+
294
+ try {
295
+ await prepareStoredExportArtifacts(candidate.value, rootDir, {
296
+ force: options?.force,
297
+ });
298
+ } catch (error: unknown) {
299
+ return Result.err(mapTopoExportError(error));
300
+ }
301
+
302
+ db = openWriteTrailsDb({ rootDir });
303
+ const persisted = persistAndReadStoredExport(app, db, rootDir, {
304
+ overlays: options?.overlays,
305
+ });
306
+ if (persisted.isErr()) {
307
+ return persisted;
308
+ }
309
+
310
+ const { snapshot, storedExport } = persisted.value;
311
+ let artifacts: Pick<TopoExportReport, 'hash' | 'lockPath'>;
312
+ try {
313
+ artifacts = await writeStoredExportArtifacts(storedExport, rootDir, {
314
+ force: options?.force,
315
+ });
316
+ } catch (error: unknown) {
317
+ return Result.err(mapTopoExportError(error));
318
+ }
319
+ return Result.ok({ ...artifacts, snapshot });
320
+ } catch (error: unknown) {
321
+ return Result.err(mapTopoExportError(error));
322
+ } finally {
323
+ db?.close();
324
+ }
325
+ };
@@ -0,0 +1,220 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ import { deriveTrailsDbPath } from '@ontrails/core';
5
+ import type { Topo } from '@ontrails/core';
6
+ import {
7
+ createTopoSnapshot as persistTopoSnapshot,
8
+ listTopoSnapshots as readTopoSnapshots,
9
+ pinTopoSnapshot,
10
+ unpinTopoSnapshot,
11
+ } from '@ontrails/topography';
12
+ import type { TopoSnapshot } from '@ontrails/topography';
13
+ import { z } from 'zod';
14
+
15
+ import {
16
+ createIsolatedExampleRoot,
17
+ writeIsolatedExampleAppModule,
18
+ } from '../local-state-io.js';
19
+
20
+ import { requireTrailRootDir } from './root-dir.js';
21
+ import type { BriefReport, SurveyListReport } from './topo-reports.js';
22
+
23
+ /** Output schema for a topo snapshot record. Shared across topo trails. */
24
+ export const topoSnapshotOutput = z.object({
25
+ createdAt: z.string(),
26
+ gitDirty: z.boolean(),
27
+ gitSha: z.string().optional(),
28
+ id: z.string(),
29
+ pinnedAs: z.string().optional(),
30
+ resourceCount: z.number(),
31
+ signalCount: z.number(),
32
+ sourceFingerprint: z.string().optional(),
33
+ trailCount: z.number(),
34
+ });
35
+
36
+ export const DEFAULT_TOPO_HISTORY_LIMIT = 10;
37
+ export const LOCK_PATH = 'trails.lock';
38
+ const EXAMPLE_APP_MODULE = fileURLToPath(new URL('../app.ts', import.meta.url));
39
+
40
+ const uniqueExampleRootName = (name: string): string =>
41
+ `${name}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
42
+
43
+ export interface TopoSummaryReport {
44
+ readonly app: BriefReport;
45
+ readonly dbPath: string;
46
+ readonly list: SurveyListReport;
47
+ readonly lockExists: boolean;
48
+ readonly lockPath: string;
49
+ }
50
+
51
+ export interface TopoHistoryReport {
52
+ readonly dbPath: string;
53
+ readonly limit: number;
54
+ readonly pinnedCount: number;
55
+ readonly snapshotCount: number;
56
+ readonly snapshots: TopoSnapshot[];
57
+ }
58
+
59
+ export interface TopoExportReport {
60
+ readonly hash: string;
61
+ readonly lockPath: string;
62
+ readonly snapshot: TopoSnapshot;
63
+ }
64
+
65
+ export interface TopoValidateReport {
66
+ readonly committedHash: string;
67
+ readonly currentHash: string;
68
+ readonly lockPath: string;
69
+ readonly stale: false;
70
+ }
71
+
72
+ export const deriveRootDir = (cwd?: string): string => requireTrailRootDir(cwd);
73
+
74
+ const safeGit = (cwd: string, args: readonly string[]): string | undefined => {
75
+ const proc = Bun.spawnSync({
76
+ cmd: ['git', '-C', cwd, ...args],
77
+ stderr: 'ignore',
78
+ stdout: 'pipe',
79
+ });
80
+ if (!proc.success) {
81
+ return undefined;
82
+ }
83
+ const text = Buffer.from(proc.stdout).toString('utf8').trim();
84
+ return text.length === 0 ? undefined : text;
85
+ };
86
+
87
+ export const readGitState = (
88
+ rootDir: string
89
+ ): { readonly gitDirty: boolean; readonly gitSha?: string } => {
90
+ const gitSha = safeGit(rootDir, ['rev-parse', 'HEAD']);
91
+ const status = safeGit(rootDir, ['status', '--porcelain']);
92
+ return {
93
+ gitDirty: (status?.length ?? 0) > 0,
94
+ ...(gitSha === undefined ? {} : { gitSha }),
95
+ };
96
+ };
97
+
98
+ export const deriveTopoCounts = (
99
+ app: Topo
100
+ ): Pick<TopoSnapshot, 'resourceCount' | 'signalCount' | 'trailCount'> => ({
101
+ resourceCount: app.resources.size,
102
+ signalCount: app.signals.size,
103
+ trailCount: app.trails.size,
104
+ });
105
+
106
+ const emptyTopoHistory = (
107
+ dbPath: string,
108
+ limit: number
109
+ ): TopoHistoryReport => ({
110
+ dbPath,
111
+ limit,
112
+ pinnedCount: 0,
113
+ snapshotCount: 0,
114
+ snapshots: [],
115
+ });
116
+
117
+ const collectTopoHistory = (
118
+ dbPath: string,
119
+ limit: number,
120
+ snapshots: readonly TopoSnapshot[]
121
+ ): TopoHistoryReport => ({
122
+ dbPath,
123
+ limit,
124
+ pinnedCount: snapshots.filter((snapshot) => snapshot.pinnedAs !== undefined)
125
+ .length,
126
+ snapshotCount: snapshots.length,
127
+ snapshots: snapshots.slice(0, limit),
128
+ });
129
+
130
+ const buildSnapshotInput = (
131
+ app: Topo,
132
+ rootDir: string
133
+ ): {
134
+ readonly gitDirty: boolean;
135
+ readonly gitSha?: string;
136
+ readonly resourceCount: number;
137
+ readonly signalCount: number;
138
+ readonly trailCount: number;
139
+ } => ({
140
+ ...readGitState(rootDir),
141
+ ...deriveTopoCounts(app),
142
+ });
143
+
144
+ export const createIsolatedExampleInput = (
145
+ name: string
146
+ ): { readonly module: string; readonly rootDir: string } => {
147
+ const rootDir = createIsolatedExampleRoot(uniqueExampleRootName(name));
148
+ return {
149
+ module: writeIsolatedExampleAppModule(rootDir, EXAMPLE_APP_MODULE),
150
+ rootDir,
151
+ };
152
+ };
153
+
154
+ export const listTopoHistory = (options?: {
155
+ readonly limit?: number;
156
+ readonly rootDir?: string;
157
+ }): TopoHistoryReport => {
158
+ const rootDir = deriveRootDir(options?.rootDir);
159
+ const limit = options?.limit ?? DEFAULT_TOPO_HISTORY_LIMIT;
160
+ const dbPath = deriveTrailsDbPath({ rootDir });
161
+ if (!existsSync(dbPath)) {
162
+ return emptyTopoHistory(dbPath, limit);
163
+ }
164
+
165
+ return collectTopoHistory(dbPath, limit, readTopoSnapshots({ rootDir }));
166
+ };
167
+
168
+ export const pinCurrentTopoSnapshot = (
169
+ app: Topo,
170
+ input: { readonly name: string; readonly rootDir?: string }
171
+ ): { readonly snapshot: TopoSnapshot } => {
172
+ const rootDir = deriveRootDir(input.rootDir);
173
+ const created = persistTopoSnapshot(app, {
174
+ rootDir,
175
+ ...buildSnapshotInput(app, rootDir),
176
+ });
177
+ if (created.isErr()) {
178
+ throw created.error;
179
+ }
180
+
181
+ const snapshot = pinTopoSnapshot(created.value.id, input.name, {
182
+ rootDir,
183
+ });
184
+ if (snapshot === undefined) {
185
+ throw new Error(`Missing topo snapshot "${created.value.id}" to pin`);
186
+ }
187
+
188
+ return { snapshot };
189
+ };
190
+
191
+ export const removePinnedTopoSnapshot = (input: {
192
+ readonly dryRun: boolean;
193
+ readonly name: string;
194
+ readonly rootDir?: string;
195
+ }): {
196
+ readonly dryRun: boolean;
197
+ readonly removed: boolean;
198
+ readonly snapshot?: TopoSnapshot;
199
+ } => {
200
+ const rootDir = deriveRootDir(input.rootDir);
201
+ if (!existsSync(deriveTrailsDbPath({ rootDir }))) {
202
+ return { dryRun: input.dryRun, removed: false };
203
+ }
204
+
205
+ if (input.dryRun) {
206
+ const snapshot = readTopoSnapshots({ pinned: true, rootDir }).find(
207
+ (candidate) => candidate.pinnedAs === input.name
208
+ );
209
+ return snapshot === undefined
210
+ ? { dryRun: true, removed: false }
211
+ : { dryRun: true, removed: false, snapshot };
212
+ }
213
+
214
+ const snapshot = unpinTopoSnapshot(input.name, { rootDir });
215
+ return {
216
+ dryRun: false,
217
+ removed: snapshot !== undefined,
218
+ ...(snapshot === undefined ? {} : { snapshot }),
219
+ };
220
+ };
@@ -0,0 +1,61 @@
1
+ import { Result, ValidationError, trail } from '@ontrails/core';
2
+ import { z } from 'zod';
3
+
4
+ import {
5
+ createIsolatedExampleInput,
6
+ removePinnedTopoSnapshot,
7
+ topoSnapshotOutput,
8
+ } from './topo-support.js';
9
+ import { resolveTrailRootDir } from './root-dir.js';
10
+
11
+ export const topoUnpinTrail = trail('topo.unpin', {
12
+ description: 'Remove a named topo pin',
13
+ examples: [
14
+ {
15
+ input: {
16
+ ...createIsolatedExampleInput('topo-unpin'),
17
+ dryRun: true,
18
+ name: 'before-auth-refactor',
19
+ },
20
+ name: 'Preview pin removal',
21
+ },
22
+ ],
23
+ implementation: (input, ctx) => {
24
+ if (input.dryRun !== true && input.yes !== true) {
25
+ return Result.err(
26
+ new ValidationError(
27
+ 'Refusing to remove a pin without `--yes` or `--dry-run`.'
28
+ )
29
+ );
30
+ }
31
+
32
+ const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
33
+ if (rootDirResult.isErr()) {
34
+ return rootDirResult;
35
+ }
36
+ const rootDir = rootDirResult.value;
37
+ return Result.ok(
38
+ removePinnedTopoSnapshot({
39
+ dryRun: input.dryRun,
40
+ name: input.name,
41
+ rootDir,
42
+ })
43
+ );
44
+ },
45
+ input: z.object({
46
+ dryRun: z
47
+ .boolean()
48
+ .default(true)
49
+ .describe('Preview the removal without changing state'),
50
+ name: z.string().describe('Pin name'),
51
+ rootDir: z.string().optional().describe('Workspace root directory'),
52
+ yes: z.boolean().default(false).describe('Confirm destructive changes'),
53
+ }),
54
+ intent: 'destroy',
55
+ output: z.object({
56
+ dryRun: z.boolean(),
57
+ removed: z.boolean(),
58
+ snapshot: topoSnapshotOutput.optional(),
59
+ }),
60
+ permit: { scopes: ['topo:write'] },
61
+ });
@@ -0,0 +1,92 @@
1
+ import { Result, trail } from '@ontrails/core';
2
+ import { z } from 'zod';
3
+
4
+ import { withFreshOperatorApp } from './operator-context.js';
5
+ import { activationOverviewOutput } from './topo-output-schemas.js';
6
+ import { deriveTopoSummary } from './topo-read-support.js';
7
+ import { createIsolatedExampleInput } from './topo-support.js';
8
+
9
+ const summaryOutput = z.object({
10
+ app: z.object({
11
+ contractVersion: z.string(),
12
+ features: z.object({
13
+ detours: z.boolean(),
14
+ examples: z.boolean(),
15
+ outputSchemas: z.boolean(),
16
+ resources: z.boolean(),
17
+ signals: z.boolean(),
18
+ }),
19
+ name: z.string(),
20
+ resources: z.number(),
21
+ signals: z.number(),
22
+ trails: z.number(),
23
+ version: z.string(),
24
+ }),
25
+ dbPath: z.string(),
26
+ list: z.object({
27
+ activation: activationOverviewOutput,
28
+ count: z.number(),
29
+ entries: z
30
+ .array(
31
+ z.object({
32
+ activatedBy: z.array(z.string()).readonly(),
33
+ activates: z.array(z.string()).readonly(),
34
+ examples: z.number(),
35
+ id: z.string(),
36
+ kind: z.string(),
37
+ safety: z.string(),
38
+ })
39
+ )
40
+ .readonly(),
41
+ resourceCount: z.number(),
42
+ resources: z
43
+ .array(
44
+ z.object({
45
+ description: z.string().nullable(),
46
+ health: z.enum(['available', 'none']),
47
+ id: z.string(),
48
+ kind: z.literal('resource'),
49
+ lifetime: z.literal('singleton'),
50
+ usedBy: z.array(z.string()).readonly(),
51
+ })
52
+ )
53
+ .readonly(),
54
+ signalCount: z.number(),
55
+ signals: z
56
+ .array(
57
+ z.object({
58
+ consumers: z.array(z.string()).readonly(),
59
+ description: z.string().nullable(),
60
+ examples: z.number(),
61
+ from: z.array(z.string()).readonly(),
62
+ id: z.string(),
63
+ kind: z.literal('signal'),
64
+ payloadSchema: z.boolean(),
65
+ producers: z.array(z.string()).readonly(),
66
+ })
67
+ )
68
+ .readonly(),
69
+ }),
70
+ lockExists: z.boolean(),
71
+ lockPath: z.string(),
72
+ });
73
+
74
+ export const topoTrail = trail('topo', {
75
+ description: 'Show the current topo summary and entry list',
76
+ examples: [
77
+ {
78
+ input: createIsolatedExampleInput('topo-summary'),
79
+ name: 'Show the current topo summary',
80
+ },
81
+ ],
82
+ implementation: async (input, ctx) =>
83
+ withFreshOperatorApp(input, ctx, ({ lease, rootDir }) =>
84
+ Result.ok(deriveTopoSummary(lease.app, { rootDir }))
85
+ ),
86
+ input: z.object({
87
+ module: z.string().optional().describe('Path to the app module'),
88
+ rootDir: z.string().optional().describe('Workspace root directory'),
89
+ }),
90
+ intent: 'read',
91
+ output: summaryOutput,
92
+ });