orizu 0.4.0 → 0.5.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 (76) hide show
  1. package/README.md +22 -13
  2. package/dist/artifact-archive.js +152 -0
  3. package/dist/connectors-cli.js +80 -0
  4. package/dist/credentials.js +4 -1
  5. package/dist/daytona-slice-rehearsal.js +255 -0
  6. package/dist/daytona-slice.js +441 -0
  7. package/dist/egress-allowlist-cli.js +132 -0
  8. package/dist/egress-policy.js +159 -0
  9. package/dist/git-credential.js +131 -0
  10. package/dist/github-setup.js +226 -0
  11. package/dist/help.js +276 -21
  12. package/dist/hosted-bootstrap-rehearsal.js +235 -0
  13. package/dist/hosted-bootstrap.js +365 -0
  14. package/dist/hosted-broker-server.js +220 -0
  15. package/dist/hosted-git-http-server.js +136 -0
  16. package/dist/hosted-harness-claude.js +493 -0
  17. package/dist/hosted-harness-opencode.js +729 -0
  18. package/dist/hosted-harness.js +60 -0
  19. package/dist/hosted-loop.js +477 -0
  20. package/dist/hosted-run-event-sink.js +499 -0
  21. package/dist/hosted-runtime-assets.js +216 -0
  22. package/dist/hosted-session-cli.js +642 -0
  23. package/dist/index.js +945 -258
  24. package/dist/kill-switch-cli.js +89 -0
  25. package/dist/manifests-cli.js +143 -0
  26. package/dist/preview-runtime.js +4 -1
  27. package/dist/sandbox-provider.js +238 -0
  28. package/dist/secret-redaction.js +112 -0
  29. package/dist/session-timeline.js +103 -0
  30. package/dist/setup-prompt.js +4 -2
  31. package/dist/skill-installer.js +9 -1
  32. package/dist/smoke.js +250 -0
  33. package/dist/task-assignment-manifest.js +96 -0
  34. package/dist/vercel-sandbox-provider.js +260 -0
  35. package/dist/workbench-cli.js +493 -0
  36. package/dist/workspace-sync.js +924 -0
  37. package/dist/workspace.js +868 -57
  38. package/package.json +4 -1
  39. package/src/artifact-archive.ts +182 -0
  40. package/src/connectors-cli.ts +115 -0
  41. package/src/credentials.ts +6 -2
  42. package/src/daytona-slice-rehearsal.ts +320 -0
  43. package/src/daytona-slice.ts +598 -0
  44. package/src/egress-allowlist-cli.ts +147 -0
  45. package/src/egress-policy.ts +188 -0
  46. package/src/git-credential.ts +155 -0
  47. package/src/github-setup.ts +366 -0
  48. package/src/help.ts +277 -21
  49. package/src/hosted-bootstrap-rehearsal.ts +312 -0
  50. package/src/hosted-bootstrap.ts +571 -0
  51. package/src/hosted-broker-server.ts +278 -0
  52. package/src/hosted-git-http-server.ts +164 -0
  53. package/src/hosted-harness-claude.ts +621 -0
  54. package/src/hosted-harness-opencode.ts +823 -0
  55. package/src/hosted-harness.ts +124 -0
  56. package/src/hosted-loop.ts +680 -0
  57. package/src/hosted-run-event-sink.ts +632 -0
  58. package/src/hosted-runtime-assets.ts +272 -0
  59. package/src/hosted-session-cli.ts +844 -0
  60. package/src/index.ts +1102 -317
  61. package/src/kill-switch-cli.ts +113 -0
  62. package/src/manifests-cli.ts +168 -0
  63. package/src/preview-runtime.ts +14 -9
  64. package/src/sandbox-provider.ts +440 -0
  65. package/src/secret-redaction.ts +124 -0
  66. package/src/session-timeline.ts +152 -0
  67. package/src/setup-prompt.ts +4 -2
  68. package/src/skill-installer.ts +13 -1
  69. package/src/smoke.ts +342 -0
  70. package/src/task-assignment-manifest.ts +164 -0
  71. package/src/vercel-sandbox-provider.ts +388 -0
  72. package/src/workbench-cli.ts +701 -0
  73. package/src/workspace-sync.ts +1148 -0
  74. package/src/workspace.ts +1051 -64
  75. package/vendor/skills/orizu-cli/SKILL.md +8 -4
  76. package/vendor/skills/orizu-cli/references/cli-reference.md +12 -4
@@ -0,0 +1,1148 @@
1
+ /**
2
+ * Workspace tracked-resource sync commands (ALI-976 CLI half, revised spec).
3
+ *
4
+ * Pure logic + injected HTTP: no process.argv parsing and no printing live in
5
+ * the command functions (the thin `workspaceSyncCommand` entry point prints).
6
+ * This module implements the client side of the ALI-933 sync semantics:
7
+ *
8
+ * - Inventory: walk the durable workspace contract (manifests + instruction/
9
+ * memory/readme files) and compute each tracked file's content sha256.
10
+ * `.orizu/**` is cache-only by definition (spec §6) and never inventoried.
11
+ * Symlinked plain files hash the LINK TARGET PATH string (spec §3.1), so
12
+ * editing `AGENTS.md` never falsely dirties a symlinked `CLAUDE.md`;
13
+ * pointer files (no-symlinks mode) hash their own bytes.
14
+ * - Missing/untracked visibility (spec §3.2): cache-known paths absent from
15
+ * the inventory are reported `missing` locally and sent to sync as
16
+ * `{path, missing: true}` (the server echoes without touching stored
17
+ * truth). Files under managed primitive dirs covered by no manifest are
18
+ * reported `untracked` in LOCAL status only — never sent to sync.
19
+ * - Cache snapshots: last-sync `base` hashes stored under
20
+ * `.orizu/cache/sync/<sha256-of-path>.json`. Filenames are path-hashed so
21
+ * the cache never mirrors the workspace directory structure. Cache is
22
+ * gitignored and safely deletable (spec §6); it is `local`/`cache`, never a
23
+ * contract.
24
+ * - Commands: `status` (local three-way against the cache; `--remote`
25
+ * performs the SAME server-side reconciliation as `sync` — it registers
26
+ * and updates tracked-resource records; there is no read-only remote
27
+ * status in v0, spec §3.1 — but never writes local cache), `sync`
28
+ * (metadata-first; cache base advances ONLY on a `clean`
29
+ * convergence-confirmation), `pull` (metadata fast-forward, only when the
30
+ * server carries real truth), and `apply` (repo → server promotion;
31
+ * refuses DB-native owners, conflicts, and remote-newer via server 409s).
32
+ *
33
+ * v0 boundary: `pull` reconciles the manifest canonical block + cache only,
34
+ * and ONLY from server-carried truth (serverSha256/serverVersionId) — never
35
+ * from local bytes. It does not materialize bulk primitive content — source
36
+ * files converge through Git, and DB-native/object bytes materialize through
37
+ * the primitive-specific commands (e.g. `orizu prompts pull`,
38
+ * `orizu datasets download`). Full bulk-content pull is deferred (spec §8).
39
+ *
40
+ * Apply identity (spec §3.1): strictly the sync-recorded tracked-resource ROW
41
+ * id from the cache snapshot (`resourceId`). There is NO fallback to the
42
+ * manifest's `canonical.serviceId` (informational/legacy). Without a recorded
43
+ * row id the command directs the user to run `orizu workspace sync` first.
44
+ * On any apply 409 (owner refusal, conflicted, remote-newer, CAS loss) the
45
+ * recovery loop is: re-sync, converge per the reported status, retry.
46
+ */
47
+
48
+ import { spawnSync } from 'child_process'
49
+ import { createHash } from 'crypto'
50
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, writeFileSync } from 'fs'
51
+ import { join, relative, sep } from 'path'
52
+
53
+ import { authedFetch } from './http.js'
54
+ import {
55
+ type CanonicalOwner,
56
+ getWorkspaceRoot,
57
+ PROJECT_PRIMITIVE_DIRS,
58
+ readJsonManifest,
59
+ type RepoState,
60
+ WORKSPACE_DIR_NAME,
61
+ } from './workspace.js'
62
+
63
+ export const TRACKED_RESOURCE_KINDS = [
64
+ 'team_manifest',
65
+ 'project_manifest',
66
+ 'instruction_file',
67
+ 'memory_file',
68
+ 'readme',
69
+ 'dataset',
70
+ 'app',
71
+ 'task',
72
+ 'prompt',
73
+ 'scorer',
74
+ 'optimization',
75
+ 'other',
76
+ ] as const
77
+
78
+ export type TrackedResourceKind = (typeof TRACKED_RESOURCE_KINDS)[number]
79
+
80
+ const PRIMITIVE_KINDS: Record<string, TrackedResourceKind> = {
81
+ dataset: 'dataset',
82
+ app: 'app',
83
+ task: 'task',
84
+ prompt: 'prompt',
85
+ scorer: 'scorer',
86
+ optimization: 'optimization',
87
+ }
88
+
89
+ const CACHE_SYNC_SUBDIR = join(WORKSPACE_DIR_NAME, 'cache', 'sync')
90
+
91
+ export type WorkspaceSyncFetcher = (path: string, init?: RequestInit) => Promise<Response>
92
+
93
+ export interface InventoryEntry {
94
+ path: string
95
+ absPath: string
96
+ kind: TrackedResourceKind
97
+ owner: CanonicalOwner
98
+ repoState: RepoState
99
+ projectSlug: string | null
100
+ serviceResourceId: string | null
101
+ versionId: string | null
102
+ contentSha256: string
103
+ manifestPath: string | null
104
+ isManifest: boolean
105
+ }
106
+
107
+ export interface CacheSnapshot {
108
+ path: string
109
+ baseSha256: string | null
110
+ versionId: string | null
111
+ lastSyncedAt: string | null
112
+ /** Server tracked-resource ROW id — the ONLY apply identity (spec §3.1). */
113
+ resourceId?: string | null
114
+ /**
115
+ * Metadata-only-pull guard (spec §3.3): set when a `pull` advanced this
116
+ * repo-owned resource's base to a server hash the working content does not
117
+ * yet match — i.e. the base moved without the bytes being delivered. While
118
+ * set, `apply` MUST refuse (a CAS apply would pass and silently revert the
119
+ * pulled version). Absent/false means not pending (backward-compatible with
120
+ * pre-guard snapshots that lack the field).
121
+ */
122
+ contentPending?: boolean
123
+ /** The pulled base hash the working content must reconcile to before apply. */
124
+ pendingBaseSha256?: string | null
125
+ }
126
+
127
+ export interface CommonOptions {
128
+ cwd?: string
129
+ fetcher?: WorkspaceSyncFetcher
130
+ workspaceId?: string | null
131
+ }
132
+
133
+ export function sha256Hex(value: string | Buffer): string {
134
+ return createHash('sha256').update(value).digest('hex')
135
+ }
136
+
137
+ function toPosix(relPath: string): string {
138
+ return relPath.split(sep).join('/')
139
+ }
140
+
141
+ function posixDirname(path: string): string {
142
+ const index = path.lastIndexOf('/')
143
+ return index === -1 ? '' : path.slice(0, index)
144
+ }
145
+
146
+ // Content hash of a manifest EXCLUDING its canonical block. The canonical block
147
+ // is metadata about the content (versionId/contentSha256/lastPulledAt move on
148
+ // apply/pull); hashing the whole file would make every manifest self-dirtying.
149
+ function manifestContentSha(manifest: Record<string, unknown>): string {
150
+ const { canonical: _canonical, ...rest } = manifest
151
+ return sha256Hex(JSON.stringify(rest))
152
+ }
153
+
154
+ // Plain-file hash (spec §3.1 "What is hashed"): a symlink hashes its LINK
155
+ // TARGET PATH string — never the resolved target's contents, which would make
156
+ // every AGENTS.md edit falsely dirty a symlinked CLAUDE.md. Regular files
157
+ // (including pointer files in no-symlinks mode) hash their own bytes.
158
+ function fileContentSha(absPath: string): string {
159
+ const stat = lstatSync(absPath)
160
+ if (stat.isSymbolicLink()) {
161
+ return sha256Hex(readlinkSync(absPath))
162
+ }
163
+ return sha256Hex(readFileSync(absPath))
164
+ }
165
+
166
+ export function readCanonical(manifest: Record<string, unknown> | null): Record<string, unknown> {
167
+ const canonical = manifest?.canonical
168
+ return canonical && typeof canonical === 'object' && !Array.isArray(canonical)
169
+ ? (canonical as Record<string, unknown>)
170
+ : {}
171
+ }
172
+
173
+ export function stringOrNull(value: unknown): string | null {
174
+ return typeof value === 'string' && value.length > 0 ? value : null
175
+ }
176
+
177
+ function ownerFrom(canonical: Record<string, unknown>): CanonicalOwner {
178
+ const owner = canonical.owner
179
+ return owner === 'orizu-db' || owner === 'object-storage' || owner === 'local' ? owner : 'repo'
180
+ }
181
+
182
+ function repoStateFrom(canonical: Record<string, unknown>, fallback: RepoState): RepoState {
183
+ const value = canonical.repoState
184
+ const allowed: RepoState[] = ['source', 'draft', 'snapshot', 'mirror', 'cache', 'object_ref_only']
185
+ return typeof value === 'string' && (allowed as string[]).includes(value) ? (value as RepoState) : fallback
186
+ }
187
+
188
+ function manifestKind(manifest: Record<string, unknown> | null, basename: string): TrackedResourceKind {
189
+ const kind = manifest?.kind
190
+ if (kind === 'team') return 'team_manifest'
191
+ if (kind === 'project') return 'project_manifest'
192
+ if (typeof kind === 'string' && PRIMITIVE_KINDS[kind]) return PRIMITIVE_KINDS[kind]
193
+ if (basename === 'orizu.team.json') return 'team_manifest'
194
+ if (basename === 'orizu.project.json') return 'project_manifest'
195
+ return 'other'
196
+ }
197
+
198
+ function plainFileKind(basename: string): TrackedResourceKind {
199
+ if (basename === 'AGENTS.md' || basename === 'CLAUDE.md') return 'instruction_file'
200
+ if (basename === 'Memory.md' || basename === 'memory.md') return 'memory_file'
201
+ if (basename === 'README.md') return 'readme'
202
+ return 'other'
203
+ }
204
+
205
+ function manifestEntry(root: string, absPath: string, projectSlug: string | null, repoStateFallback: RepoState): InventoryEntry | null {
206
+ const manifest = readJsonManifest(absPath)
207
+ if (!manifest) {
208
+ return null
209
+ }
210
+ const canonical = readCanonical(manifest)
211
+ const basename = absPath.split(sep).pop() || absPath
212
+ return {
213
+ path: toPosix(relative(root, absPath)),
214
+ absPath,
215
+ kind: manifestKind(manifest, basename),
216
+ owner: ownerFrom(canonical),
217
+ repoState: repoStateFrom(canonical, repoStateFallback),
218
+ projectSlug,
219
+ serviceResourceId: stringOrNull(canonical.serviceId),
220
+ versionId: stringOrNull(canonical.versionId),
221
+ contentSha256: manifestContentSha(manifest),
222
+ manifestPath: absPath,
223
+ isManifest: true,
224
+ }
225
+ }
226
+
227
+ function plainEntry(root: string, absPath: string, projectSlug: string | null): InventoryEntry {
228
+ const basename = absPath.split(sep).pop() || absPath
229
+ return {
230
+ path: toPosix(relative(root, absPath)),
231
+ absPath,
232
+ kind: plainFileKind(basename),
233
+ owner: 'repo',
234
+ repoState: 'source',
235
+ projectSlug,
236
+ serviceResourceId: null,
237
+ versionId: null,
238
+ contentSha256: fileContentSha(absPath),
239
+ manifestPath: null,
240
+ isManifest: false,
241
+ }
242
+ }
243
+
244
+ function projectDirectoryNames(projectsRoot: string): string[] {
245
+ try {
246
+ return readdirSync(projectsRoot, { withFileTypes: true })
247
+ .filter(entry => entry.isDirectory())
248
+ .map(entry => entry.name)
249
+ .sort()
250
+ } catch {
251
+ return []
252
+ }
253
+ }
254
+
255
+ function walkPrimitiveManifests(dir: string, depth: number, into: string[]) {
256
+ if (depth > 8 || !existsSync(dir)) {
257
+ return
258
+ }
259
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
260
+ if (entry.name === WORKSPACE_DIR_NAME || entry.name === '.git') {
261
+ continue
262
+ }
263
+ const path = join(dir, entry.name)
264
+ if (entry.isDirectory()) {
265
+ walkPrimitiveManifests(path, depth + 1, into)
266
+ } else if (/^orizu\..+\.json$/.test(entry.name)) {
267
+ into.push(path)
268
+ }
269
+ }
270
+ }
271
+
272
+ function walkAllFiles(dir: string, depth: number, into: string[]) {
273
+ if (depth > 8 || !existsSync(dir)) {
274
+ return
275
+ }
276
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
277
+ if (entry.name === WORKSPACE_DIR_NAME || entry.name === '.git') {
278
+ continue
279
+ }
280
+ const path = join(dir, entry.name)
281
+ if (entry.isDirectory()) {
282
+ walkAllFiles(path, depth + 1, into)
283
+ } else {
284
+ into.push(path)
285
+ }
286
+ }
287
+ }
288
+
289
+ /** Walk the durable workspace contract and return one entry per tracked file. */
290
+ export function collectInventory(cwd?: string): InventoryEntry[] {
291
+ const root = getWorkspaceRoot(cwd)
292
+ const entries: InventoryEntry[] = []
293
+
294
+ const teamManifest = join(root, 'orizu.team.json')
295
+ if (existsSync(teamManifest)) {
296
+ const entry = manifestEntry(root, teamManifest, null, 'source')
297
+ if (entry) entries.push(entry)
298
+ }
299
+ for (const name of ['AGENTS.md', 'CLAUDE.md', 'Memory.md', 'README.md']) {
300
+ const path = join(root, name)
301
+ if (existsSync(path)) entries.push(plainEntry(root, path, null))
302
+ }
303
+
304
+ const projectsRoot = join(root, 'projects')
305
+ for (const slug of projectDirectoryNames(projectsRoot)) {
306
+ const projectRoot = join(projectsRoot, slug)
307
+ const projectManifest = join(projectRoot, 'orizu.project.json')
308
+ if (existsSync(projectManifest)) {
309
+ const entry = manifestEntry(root, projectManifest, slug, 'draft')
310
+ if (entry) entries.push(entry)
311
+ }
312
+ for (const name of ['README.md', 'memory.md']) {
313
+ const path = join(projectRoot, name)
314
+ if (existsSync(path)) entries.push(plainEntry(root, path, slug))
315
+ }
316
+ for (const primitiveDir of PROJECT_PRIMITIVE_DIRS) {
317
+ const manifests: string[] = []
318
+ walkPrimitiveManifests(join(projectRoot, primitiveDir), 0, manifests)
319
+ for (const path of manifests) {
320
+ const entry = manifestEntry(root, path, slug, 'draft')
321
+ if (entry) entries.push(entry)
322
+ }
323
+ }
324
+ }
325
+
326
+ return entries
327
+ }
328
+
329
+ /**
330
+ * Files present under managed primitive dirs that no inventory rule covers
331
+ * (spec §3.2 `untracked`). A file is covered when an inventoried manifest
332
+ * lives in its directory or any ancestor within the managed dir — body files
333
+ * adjacent to their manifest belong to that resource and converge via Git.
334
+ * Reported in LOCAL status only; never sent to sync.
335
+ */
336
+ export function collectUntrackedPaths(cwd: string, inventory: InventoryEntry[]): string[] {
337
+ const root = getWorkspaceRoot(cwd)
338
+ const inventoryPaths = new Set(inventory.map(entry => entry.path))
339
+ const manifestDirs = new Set(inventory.filter(entry => entry.isManifest).map(entry => posixDirname(entry.path)))
340
+ const untracked: string[] = []
341
+ const projectsRoot = join(root, 'projects')
342
+
343
+ for (const slug of projectDirectoryNames(projectsRoot)) {
344
+ for (const primitiveDir of PROJECT_PRIMITIVE_DIRS) {
345
+ const managedRel = `projects/${slug}/${primitiveDir}`
346
+ const files: string[] = []
347
+ walkAllFiles(join(projectsRoot, slug, primitiveDir), 0, files)
348
+ for (const absPath of files) {
349
+ const relPath = toPosix(relative(root, absPath))
350
+ if (inventoryPaths.has(relPath)) {
351
+ continue
352
+ }
353
+ let dir = posixDirname(relPath)
354
+ let covered = false
355
+ while (dir.length >= managedRel.length) {
356
+ if (manifestDirs.has(dir)) {
357
+ covered = true
358
+ break
359
+ }
360
+ if (dir === managedRel) {
361
+ break
362
+ }
363
+ dir = posixDirname(dir)
364
+ }
365
+ if (!covered) {
366
+ untracked.push(relPath)
367
+ }
368
+ }
369
+ }
370
+ }
371
+
372
+ return untracked.sort()
373
+ }
374
+
375
+ // ---- Cache snapshots ------------------------------------------------------
376
+
377
+ export function cacheSnapshotPath(cwd: string, relPath: string): string {
378
+ return join(getWorkspaceRoot(cwd), CACHE_SYNC_SUBDIR, `${sha256Hex(relPath)}.json`)
379
+ }
380
+
381
+ export function readCacheSnapshot(cwd: string, relPath: string): CacheSnapshot | null {
382
+ const file = cacheSnapshotPath(cwd, relPath)
383
+ if (!existsSync(file)) {
384
+ return null
385
+ }
386
+ try {
387
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as CacheSnapshot
388
+ return parsed && typeof parsed === 'object' ? parsed : null
389
+ } catch {
390
+ return null
391
+ }
392
+ }
393
+
394
+ export function writeCacheSnapshot(cwd: string, snapshot: CacheSnapshot): void {
395
+ const file = cacheSnapshotPath(cwd, snapshot.path)
396
+ mkdirSync(join(getWorkspaceRoot(cwd), CACHE_SYNC_SUBDIR), { recursive: true })
397
+ writeFileSync(file, `${JSON.stringify(snapshot, null, 2)}\n`)
398
+ }
399
+
400
+ /** Every readable cache snapshot (regenerable; unreadable files are skipped). */
401
+ function listCacheSnapshots(cwd: string): CacheSnapshot[] {
402
+ const dir = join(getWorkspaceRoot(cwd), CACHE_SYNC_SUBDIR)
403
+ let names: string[]
404
+ try {
405
+ names = readdirSync(dir).filter(name => name.endsWith('.json')).sort()
406
+ } catch {
407
+ return []
408
+ }
409
+ const snapshots: CacheSnapshot[] = []
410
+ for (const name of names) {
411
+ try {
412
+ const parsed = JSON.parse(readFileSync(join(dir, name), 'utf8')) as CacheSnapshot
413
+ if (parsed && typeof parsed === 'object' && typeof parsed.path === 'string') {
414
+ snapshots.push(parsed)
415
+ }
416
+ } catch {
417
+ // Cache is regenerable by definition (§6); skip unreadable snapshots.
418
+ }
419
+ }
420
+ return snapshots
421
+ }
422
+
423
+ /** Cache-known paths absent from the inventory — the `missing` transport (§3.1). */
424
+ function missingCachePaths(cwd: string, inventory: InventoryEntry[]): string[] {
425
+ const inventoryPaths = new Set(inventory.map(entry => entry.path))
426
+ return listCacheSnapshots(cwd)
427
+ .map(snapshot => snapshot.path)
428
+ .filter(path => !inventoryPaths.has(path))
429
+ }
430
+
431
+ // Record the server row id WITHOUT advancing the base: base/versionId move
432
+ // only on a clean confirmation or on pull/apply; the row id is pure identity
433
+ // and is safe to persist for every sync response.
434
+ function recordCacheResourceId(cwd: string, path: string, resourceId: string | null): void {
435
+ if (!resourceId) {
436
+ return
437
+ }
438
+ const existing = readCacheSnapshot(cwd, path)
439
+ if (existing?.resourceId === resourceId) {
440
+ return
441
+ }
442
+ // Preserve every other field (base/version/lastSynced AND the §3.3
443
+ // content-pending guard): recording a row id must never advance the base nor
444
+ // silently clear a pending mark. A non-clean sync between pull and apply keeps
445
+ // the guard intact; only a `clean` convergence (or a reconciled apply/pull)
446
+ // clears it.
447
+ writeCacheSnapshot(cwd, {
448
+ ...(existing ?? { path, baseSha256: null, versionId: null, lastSyncedAt: null }),
449
+ path,
450
+ resourceId,
451
+ })
452
+ }
453
+
454
+ // ---- Manifest edits (preserve all other fields + formatting) --------------
455
+
456
+ function writeManifest(absPath: string, manifest: Record<string, unknown>): void {
457
+ writeFileSync(absPath, `${JSON.stringify(manifest, null, 2)}\n`)
458
+ }
459
+
460
+ function patchCanonical(absPath: string, patch: Record<string, unknown>): void {
461
+ const manifest = readJsonManifest(absPath) ?? {}
462
+ const canonical = { ...readCanonical(manifest), ...patch }
463
+ writeManifest(absPath, { ...manifest, canonical })
464
+ }
465
+
466
+ function patchTeamWorkspaceId(root: string, workspaceId: string): void {
467
+ const absPath = join(root, 'orizu.team.json')
468
+ const manifest = readJsonManifest(absPath) ?? {}
469
+ const canonical = { ...readCanonical(manifest), serviceId: workspaceId }
470
+ const setupRaw = manifest.setup
471
+ const setup = { ...(setupRaw && typeof setupRaw === 'object' && !Array.isArray(setupRaw) ? setupRaw : {}), attachedWorkspaceId: workspaceId }
472
+ writeManifest(absPath, { ...manifest, canonical, setup })
473
+ }
474
+
475
+ // ---- HTTP helpers ---------------------------------------------------------
476
+
477
+ async function readJson(response: Response): Promise<Record<string, unknown>> {
478
+ try {
479
+ return (await response.json()) as Record<string, unknown>
480
+ } catch {
481
+ return {}
482
+ }
483
+ }
484
+
485
+ interface SyncResourceResult {
486
+ /** Server tracked-resource ROW id (null for untracked — no row exists). */
487
+ id: string | null
488
+ path: string
489
+ status: string
490
+ serverVersionId: string | null
491
+ serverSha256: string | null
492
+ lastSyncedAt: string | null
493
+ }
494
+
495
+ function teamSlugOf(root: string): string {
496
+ const manifest = readJsonManifest(join(root, 'orizu.team.json'))
497
+ return stringOrNull(manifest?.slug) || 'local-team'
498
+ }
499
+
500
+ export function attachedWorkspaceId(root: string): string | null {
501
+ const manifest = readJsonManifest(join(root, 'orizu.team.json'))
502
+ const setup = manifest?.setup
503
+ const fromSetup = setup && typeof setup === 'object' && !Array.isArray(setup)
504
+ ? stringOrNull((setup as Record<string, unknown>).attachedWorkspaceId)
505
+ : null
506
+ return fromSetup || stringOrNull(readCanonical(manifest).serviceId)
507
+ }
508
+
509
+ // Create-or-attach the server workspace. Deliberately does NOT write the
510
+ // manifest: the caller persists the id only after the first sync round-trip
511
+ // succeeds, so a failed sync never leaves a partially-attached manifest.
512
+ async function createOrAttachWorkspace(root: string, fetcher: WorkspaceSyncFetcher): Promise<string> {
513
+ const teamSlug = teamSlugOf(root)
514
+ const response = await fetcher('/api/cli/workspaces', {
515
+ method: 'POST',
516
+ headers: { 'Content-Type': 'application/json' },
517
+ body: JSON.stringify({ teamSlug, name: teamSlug, slug: teamSlug }),
518
+ })
519
+ if (!response.ok) {
520
+ const error = await readJson(response)
521
+ throw new Error(`Failed to attach workspace: ${stringOrNull(error.error) || response.status}`)
522
+ }
523
+ const data = await readJson(response)
524
+ const workspace = data.workspace as Record<string, unknown> | undefined
525
+ const id = stringOrNull(workspace?.id)
526
+ if (!id) {
527
+ throw new Error('Workspace attach response did not include an id')
528
+ }
529
+ return id
530
+ }
531
+
532
+ function requireAttachedWorkspaceId(root: string, opts: CommonOptions): string {
533
+ const known = stringOrNull(opts.workspaceId ?? undefined) || attachedWorkspaceId(root)
534
+ if (!known) {
535
+ throw new Error('This workspace is not attached yet. Run `orizu workspace sync` first to attach it.')
536
+ }
537
+ return known
538
+ }
539
+
540
+ // One entry per inventoried path plus `{path, missing: true}` for every
541
+ // cache-known path absent from the inventory (spec §3.1 missing transport).
542
+ function syncBody(root: string, entries: InventoryEntry[]) {
543
+ return {
544
+ resources: [
545
+ ...entries.map(entry => ({
546
+ path: entry.path,
547
+ kind: entry.kind,
548
+ projectSlug: entry.projectSlug,
549
+ canonicalOwner: entry.owner,
550
+ repoState: entry.repoState,
551
+ serviceResourceId: entry.serviceResourceId,
552
+ versionId: entry.versionId,
553
+ contentSha256: entry.contentSha256,
554
+ baseSha256: readCacheSnapshot(root, entry.path)?.baseSha256 ?? null,
555
+ })),
556
+ ...missingCachePaths(root, entries).map(path => ({ path, missing: true })),
557
+ ],
558
+ }
559
+ }
560
+
561
+ async function callSync(root: string, workspaceId: string, entries: InventoryEntry[], fetcher: WorkspaceSyncFetcher): Promise<SyncResourceResult[]> {
562
+ const response = await fetcher(`/api/cli/workspaces/${workspaceId}/sync`, {
563
+ method: 'POST',
564
+ headers: { 'Content-Type': 'application/json' },
565
+ body: JSON.stringify(syncBody(root, entries)),
566
+ })
567
+ if (!response.ok) {
568
+ const error = await readJson(response)
569
+ throw new Error(`Sync failed: ${stringOrNull(error.error) || response.status}`)
570
+ }
571
+ const data = await readJson(response)
572
+ return (Array.isArray(data.resources) ? data.resources : []) as SyncResourceResult[]
573
+ }
574
+
575
+ // ---- Commands -------------------------------------------------------------
576
+
577
+ export interface StatusResourceLine {
578
+ path: string
579
+ kind: TrackedResourceKind | null
580
+ owner: CanonicalOwner | null
581
+ repoState: RepoState | null
582
+ localSha: string | null
583
+ baseSha: string | null
584
+ dirty: boolean
585
+ /**
586
+ * Metadata-only-pull guard (spec §3.3): the base was pulled ahead of the
587
+ * working content, so `apply` will refuse until the bytes are delivered.
588
+ */
589
+ contentPending?: boolean
590
+ /** Definitional local statuses: cache-known-but-deleted / rule-less files. */
591
+ localStatus?: 'missing' | 'untracked'
592
+ remoteStatus?: string
593
+ serverVersionId?: string | null
594
+ }
595
+
596
+ // -- Hosted-repo freshness + stale-branch report (ALI-996 / WS-G, ALI-972) ----
597
+
598
+ /** Resolves a git ref (e.g. `origin/main`) to a SHA; null when absent/non-git. */
599
+ export type GitRefReader = (cwd: string, ref: string) => string | null
600
+
601
+ function defaultGitRef(cwd: string, ref: string): string | null {
602
+ try {
603
+ // Strip repo-context env (set by git inside hooks) so the query answers
604
+ // about `cwd`, not whatever repo GIT_DIR points at.
605
+ const env: NodeJS.ProcessEnv = { ...process.env }
606
+ delete env.GIT_DIR
607
+ delete env.GIT_WORK_TREE
608
+ delete env.GIT_INDEX_FILE
609
+ const result = spawnSync('git', ['rev-parse', '--verify', '--quiet', ref], { cwd, encoding: 'utf8', env })
610
+ if (result.status !== 0 || typeof result.stdout !== 'string') {
611
+ return null
612
+ }
613
+ return result.stdout.trim() || null
614
+ } catch {
615
+ return null
616
+ }
617
+ }
618
+
619
+ /**
620
+ * "repo moved since your last pull" — the server's recorded DEFAULT-branch push
621
+ * SHA differs from what the LOCAL repo knows the default branch to be. Compared
622
+ * against the default-branch ref (never bare HEAD) so being on a feature branch
623
+ * or carrying local commits never triggers a false positive.
624
+ */
625
+ export interface FreshnessSignal {
626
+ serverSha: string
627
+ branch: string | null
628
+ pushedAt: string | null
629
+ localDefaultSha: string
630
+ }
631
+
632
+ /** A session branch worth reporting: an old active session or an ended, unmerged one. */
633
+ export interface StaleSessionBranch {
634
+ sessionId: string
635
+ branch: string
636
+ startedAt: string | null
637
+ reason: 'active-stale' | 'unmerged'
638
+ }
639
+
640
+ const STALE_ACTIVE_MS = 24 * 60 * 60 * 1000
641
+
642
+ // Fetch the workspace record and compare its recorded DEFAULT-branch push SHA
643
+ // against what the local repo knows that same branch to be. Best-effort: any
644
+ // server/non-hosted/non-git/unknown-branch condition yields null so `status`
645
+ // never fails — and never false-warns — for a local-only, offline, or
646
+ // feature-branch checkout.
647
+ async function fetchFreshness(
648
+ root: string,
649
+ workspaceId: string,
650
+ fetcher: WorkspaceSyncFetcher,
651
+ gitRef: GitRefReader
652
+ ): Promise<FreshnessSignal | null> {
653
+ const teamSlug = teamSlugOf(root)
654
+ const response = await fetcher(`/api/cli/workspaces?team=${encodeURIComponent(teamSlug)}`)
655
+ if (!response.ok) return null
656
+ const data = await readJson(response)
657
+ const workspaces = Array.isArray(data.workspaces) ? (data.workspaces as Array<Record<string, unknown>>) : []
658
+ const ws = workspaces.find(item => stringOrNull(item.id) === workspaceId) ?? null
659
+ const serverSha = ws ? stringOrNull(ws.repoLastPushSha) : null
660
+ const branch = ws ? stringOrNull(ws.repoLastPushBranch) : null
661
+ // Without the server's default-branch name we cannot locate the right local
662
+ // ref to compare — stay silent rather than compare against the wrong branch.
663
+ if (!serverSha || !branch) return null
664
+ // Prefer the remote-tracking ref (what the last fetch/pull observed on the
665
+ // default branch); fall back to the local branch. HEAD is deliberately NOT
666
+ // used: a feature-branch checkout must never read as "repo moved".
667
+ const localDefaultSha = gitRef(root, `origin/${branch}`) ?? gitRef(root, branch)
668
+ if (!localDefaultSha || localDefaultSha === serverSha) return null
669
+ return {
670
+ serverSha,
671
+ branch,
672
+ pushedAt: stringOrNull(ws?.repoLastPushAt ?? null),
673
+ localDefaultSha,
674
+ }
675
+ }
676
+
677
+ // List the workspace's branched sessions and flag the stale ones. Report,
678
+ // don't reap (ALI-972): active sessions still holding a branch past 24h, and
679
+ // ended sessions whose branch metadata persists (unmerged write-back).
680
+ async function fetchStaleSessionBranches(
681
+ workspaceId: string,
682
+ fetcher: WorkspaceSyncFetcher,
683
+ now: number
684
+ ): Promise<StaleSessionBranch[]> {
685
+ const response = await fetcher(`/api/cli/workspaces/${workspaceId}/sessions?branched=true`)
686
+ if (!response.ok) return []
687
+ const data = await readJson(response)
688
+ const sessions = Array.isArray(data.sessions) ? (data.sessions as Array<Record<string, unknown>>) : []
689
+ const stale: StaleSessionBranch[] = []
690
+ for (const session of sessions) {
691
+ const branch = stringOrNull(session.repoBranch)
692
+ if (!branch) continue
693
+ const status = stringOrNull(session.status)
694
+ const startedAt = stringOrNull(session.startedAt)
695
+ const sessionId = stringOrNull(session.id) ?? ''
696
+ if (status === 'active') {
697
+ const started = startedAt ? Date.parse(startedAt) : NaN
698
+ if (!Number.isNaN(started) && now - started > STALE_ACTIVE_MS) {
699
+ stale.push({ sessionId, branch, startedAt, reason: 'active-stale' })
700
+ }
701
+ } else if (status === 'ended') {
702
+ stale.push({ sessionId, branch, startedAt, reason: 'unmerged' })
703
+ }
704
+ }
705
+ return stale
706
+ }
707
+
708
+ export interface StatusResult {
709
+ root: string
710
+ workspaceId: string | null
711
+ remote: boolean
712
+ resources: StatusResourceLine[]
713
+ /** Set only when the hosted repo's default branch moved past the local ref (ALI-996). */
714
+ freshness?: FreshnessSignal | null
715
+ /** Branched sessions worth attention; empty for local-only workspaces (ALI-972). */
716
+ staleSessionBranches?: StaleSessionBranch[]
717
+ }
718
+
719
+ export async function runWorkspaceStatus(
720
+ opts: CommonOptions & { remote?: boolean; gitRef?: GitRefReader; now?: () => number } = {}
721
+ ): Promise<StatusResult> {
722
+ const root = getWorkspaceRoot(opts.cwd)
723
+ const fetcher = opts.fetcher ?? authedFetch
724
+ const inventory = collectInventory(root)
725
+
726
+ const lines: StatusResourceLine[] = inventory.map(entry => {
727
+ const snapshot = readCacheSnapshot(root, entry.path)
728
+ const base = snapshot?.baseSha256 ?? null
729
+ // Surface the §3.3 guard only while it still bites: if the working content
730
+ // has since reconciled to the pending base, apply would clear it and proceed.
731
+ const pendingBase = snapshot?.contentPending === true
732
+ ? stringOrNull(snapshot.pendingBaseSha256 ?? snapshot.baseSha256 ?? null)
733
+ : null
734
+ const contentPending = pendingBase !== null && pendingBase !== entry.contentSha256
735
+ return {
736
+ path: entry.path,
737
+ kind: entry.kind,
738
+ owner: entry.owner,
739
+ repoState: entry.repoState,
740
+ localSha: entry.contentSha256,
741
+ baseSha: base,
742
+ dirty: base !== null && base !== entry.contentSha256,
743
+ ...(contentPending ? { contentPending: true } : {}),
744
+ }
745
+ })
746
+
747
+ // Cache-known paths that no longer exist on disk (spec §3.2 `missing`).
748
+ for (const path of missingCachePaths(root, inventory)) {
749
+ lines.push({
750
+ path,
751
+ kind: null,
752
+ owner: null,
753
+ repoState: null,
754
+ localSha: null,
755
+ baseSha: readCacheSnapshot(root, path)?.baseSha256 ?? null,
756
+ dirty: false,
757
+ localStatus: 'missing',
758
+ })
759
+ }
760
+
761
+ // Rule-less files under managed dirs (spec §3.2 `untracked`) — local only.
762
+ for (const path of collectUntrackedPaths(root, inventory)) {
763
+ lines.push({
764
+ path,
765
+ kind: null,
766
+ owner: null,
767
+ repoState: null,
768
+ localSha: null,
769
+ baseSha: null,
770
+ dirty: false,
771
+ localStatus: 'untracked',
772
+ })
773
+ }
774
+
775
+ let workspaceId: string | null = opts.workspaceId ?? attachedWorkspaceId(root)
776
+ if (opts.remote) {
777
+ // No read-only remote status exists in v0 (spec §3.1): this is the same
778
+ // server-side reconciliation as `sync` (it registers/updates records).
779
+ // It never writes the local cache, though.
780
+ workspaceId = requireAttachedWorkspaceId(root, opts)
781
+ const remote = await callSync(root, workspaceId, inventory, fetcher)
782
+ const byPath = new Map(remote.map(item => [item.path, item]))
783
+ for (const line of lines) {
784
+ const match = byPath.get(line.path)
785
+ if (match) {
786
+ line.remoteStatus = match.status
787
+ line.serverVersionId = match.serverVersionId
788
+ }
789
+ }
790
+ }
791
+
792
+ // Hosted-repo freshness + stale-branch report (best-effort): only for attached
793
+ // workspaces, and every server/non-git failure is swallowed so a local-only or
794
+ // offline `status` behaves exactly as before.
795
+ let freshness: FreshnessSignal | null = null
796
+ let staleSessionBranches: StaleSessionBranch[] = []
797
+ if (workspaceId) {
798
+ const gitRef = opts.gitRef ?? defaultGitRef
799
+ const now = (opts.now ?? (() => Date.now()))()
800
+ try {
801
+ freshness = await fetchFreshness(root, workspaceId, fetcher, gitRef)
802
+ } catch {
803
+ freshness = null
804
+ }
805
+ try {
806
+ staleSessionBranches = await fetchStaleSessionBranches(workspaceId, fetcher, now)
807
+ } catch {
808
+ staleSessionBranches = []
809
+ }
810
+ }
811
+
812
+ return { root, workspaceId, remote: Boolean(opts.remote), resources: lines, freshness, staleSessionBranches }
813
+ }
814
+
815
+ export interface SyncResultLine extends SyncResourceResult {
816
+ cacheAdvanced: boolean
817
+ }
818
+
819
+ export interface SyncResult {
820
+ root: string
821
+ workspaceId: string
822
+ resources: SyncResultLine[]
823
+ }
824
+
825
+ export async function runWorkspaceSync(opts: CommonOptions = {}): Promise<SyncResult> {
826
+ const root = getWorkspaceRoot(opts.cwd)
827
+ const fetcher = opts.fetcher ?? authedFetch
828
+ const knownId = stringOrNull(opts.workspaceId ?? undefined) || attachedWorkspaceId(root)
829
+ const workspaceId = knownId ?? await createOrAttachWorkspace(root, fetcher)
830
+ const inventory = collectInventory(root)
831
+ const remote = await callSync(root, workspaceId, inventory, fetcher)
832
+
833
+ // Persist the attachment only after the sync round-trip succeeded, so a
834
+ // failed first sync leaves orizu.team.json byte-identical.
835
+ if (!knownId) {
836
+ patchTeamWorkspaceId(root, workspaceId)
837
+ }
838
+
839
+ const resources: SyncResultLine[] = remote.map(item => {
840
+ // Cache base advances ONLY on a `clean` convergence confirmation; every
841
+ // other status leaves the last-sync base untouched (spec §3.1 metadata-first).
842
+ // The server row id, however, is recorded for EVERY returned resource —
843
+ // it is what `apply` addresses, so losing it would strand new resources.
844
+ const resourceId = stringOrNull(item.id) ?? readCacheSnapshot(root, item.path)?.resourceId ?? null
845
+ let cacheAdvanced = false
846
+ if (item.status === 'clean') {
847
+ writeCacheSnapshot(root, {
848
+ path: item.path,
849
+ baseSha256: item.serverSha256,
850
+ versionId: item.serverVersionId,
851
+ lastSyncedAt: item.lastSyncedAt,
852
+ resourceId,
853
+ })
854
+ cacheAdvanced = true
855
+ } else {
856
+ recordCacheResourceId(root, item.path, resourceId)
857
+ }
858
+ return { ...item, cacheAdvanced }
859
+ })
860
+
861
+ return { root, workspaceId, resources }
862
+ }
863
+
864
+ export interface PullResult {
865
+ path: string
866
+ status: 'converged' | 'refused' | 'noop'
867
+ remoteStatus: string | null
868
+ reason?: string
869
+ note?: string
870
+ manifestUpdated: boolean
871
+ cacheAdvanced: boolean
872
+ }
873
+
874
+ export async function runWorkspacePull(targetPath: string, opts: CommonOptions = {}): Promise<PullResult> {
875
+ const root = getWorkspaceRoot(opts.cwd)
876
+ const fetcher = opts.fetcher ?? authedFetch
877
+ const entry = collectInventory(root).find(item => item.path === targetPath)
878
+ if (!entry) {
879
+ const known = readCacheSnapshot(root, targetPath)
880
+ return {
881
+ path: targetPath,
882
+ status: 'noop',
883
+ remoteStatus: null,
884
+ reason: known
885
+ ? `File is missing locally at ${targetPath}. Restore it via Git; v0 pull reconciles metadata only and does not materialize content.`
886
+ : `No tracked resource at ${targetPath}`,
887
+ manifestUpdated: false,
888
+ cacheAdvanced: false,
889
+ }
890
+ }
891
+
892
+ const workspaceId = requireAttachedWorkspaceId(root, opts)
893
+ const results = await callSync(root, workspaceId, [entry], fetcher)
894
+ const remote = results.find(item => item.path === entry.path) ?? null
895
+ const remoteStatus = remote?.status ?? null
896
+
897
+ if (remoteStatus === 'divergent' || remoteStatus === 'conflicted') {
898
+ return {
899
+ path: targetPath,
900
+ status: 'refused',
901
+ remoteStatus,
902
+ reason: 'Pull refused: local edits are ahead of the last-sync base; resolve in the working tree (Git) or `apply` to promote.',
903
+ manifestUpdated: false,
904
+ cacheAdvanced: false,
905
+ }
906
+ }
907
+
908
+ // Fast-forward ONLY from server-carried truth. Truthless statuses
909
+ // (local-only/stale/missing/untracked) have nothing to pull; fabricating a
910
+ // base from local bytes is forbidden (spec §3.2).
911
+ const serverSha256 = stringOrNull(remote?.serverSha256 ?? null)
912
+ const serverVersionId = stringOrNull(remote?.serverVersionId ?? null)
913
+ if (!remote || serverSha256 === null) {
914
+ const reason = remoteStatus === 'stale'
915
+ ? 'Nothing to pull: no verifiable server truth for this resource. Refresh it through the primitive-specific command (e.g. `orizu prompts pull`, `orizu datasets download`), then sync.'
916
+ : 'Nothing to pull: this resource has no server truth yet. `orizu workspace apply` promotes repo-owned resources; run `orizu workspace sync` first if it is not registered.'
917
+ return { path: targetPath, status: 'noop', remoteStatus, reason, manifestUpdated: false, cacheAdvanced: false }
918
+ }
919
+
920
+ // Metadata fast-forward: advance cache base + manifest canonical to the
921
+ // SERVER truth (never local bytes). v0 is metadata-level: source bytes
922
+ // converge through Git; DB-native/object bytes materialize via the
923
+ // primitive-specific commands.
924
+ //
925
+ // Metadata-only-pull guard (spec §3.3): this pull advanced the base to the
926
+ // server hash WITHOUT delivering the bytes. For a repo-owned resource whose
927
+ // working content does not already match that hash, the working tree is now
928
+ // BEHIND the base — a naive three-way read would call it `divergent` and a
929
+ // CAS `apply` would pass (base == server truth) and silently revert the
930
+ // pulled version. Mark the resource content-pending so `apply` refuses until
931
+ // Git delivers the bytes (working == base again). If the content already
932
+ // matches (Git first, the §7 happy path), leave it unmarked.
933
+ const contentPending = entry.owner === 'repo' && entry.contentSha256 !== serverSha256
934
+ writeCacheSnapshot(root, {
935
+ path: entry.path,
936
+ baseSha256: serverSha256,
937
+ versionId: serverVersionId,
938
+ lastSyncedAt: remote.lastSyncedAt ?? new Date().toISOString(),
939
+ resourceId: stringOrNull(remote.id) ?? readCacheSnapshot(root, entry.path)?.resourceId ?? null,
940
+ ...(contentPending
941
+ ? { contentPending: true, pendingBaseSha256: serverSha256 }
942
+ : {}),
943
+ })
944
+ let manifestUpdated = false
945
+ if (entry.manifestPath) {
946
+ patchCanonical(entry.manifestPath, {
947
+ versionId: serverVersionId,
948
+ contentSha256: serverSha256,
949
+ lastPulledAt: remote.lastSyncedAt ?? new Date().toISOString(),
950
+ })
951
+ manifestUpdated = true
952
+ }
953
+
954
+ const note = contentPending
955
+ ? 'Base advanced to the server version but the working content has not been delivered yet (content-pending). `apply` will refuse until Git delivers the new bytes (working == base); run `git pull` to fetch them first.'
956
+ : entry.owner === 'repo'
957
+ ? undefined
958
+ : 'v0 pull reconciles metadata only; materialize bulk content with the primitive-specific command (e.g. `orizu prompts pull`, `orizu datasets download`).'
959
+ return { path: targetPath, status: 'converged', remoteStatus, note, manifestUpdated, cacheAdvanced: true }
960
+ }
961
+
962
+ export interface ApplyResult {
963
+ path: string
964
+ status: 'applied' | 'refused' | 'noop'
965
+ reason?: string
966
+ /** Recovery guidance for server 409 refusals (re-sync, then re-drive). */
967
+ hint?: string
968
+ versionId?: string | null
969
+ contentSha256?: string | null
970
+ lastSyncedAt?: string | null
971
+ manifestUpdated: boolean
972
+ cacheAdvanced: boolean
973
+ }
974
+
975
+ export async function runWorkspaceApply(targetPath: string, opts: CommonOptions = {}): Promise<ApplyResult> {
976
+ const root = getWorkspaceRoot(opts.cwd)
977
+ const fetcher = opts.fetcher ?? authedFetch
978
+ const entry = collectInventory(root).find(item => item.path === targetPath)
979
+ if (!entry) {
980
+ return { path: targetPath, status: 'noop', reason: `No tracked resource at ${targetPath}`, manifestUpdated: false, cacheAdvanced: false }
981
+ }
982
+ if (entry.owner !== 'repo') {
983
+ return {
984
+ path: targetPath,
985
+ status: 'refused',
986
+ reason: `Apply refused: ${entry.owner} resources are pull-only; change DB-native state through the primitive's own command and promotion flow.`,
987
+ manifestUpdated: false,
988
+ cacheAdvanced: false,
989
+ }
990
+ }
991
+ // Apply identity (spec §3.1): STRICTLY the sync-recorded tracked-resource
992
+ // row id. The manifest's canonical.serviceId is informational/legacy and is
993
+ // never used — the API resolves by row id only, so a serviceId would 404.
994
+ const snapshot = readCacheSnapshot(root, entry.path)
995
+ const resourceId = stringOrNull(snapshot?.resourceId ?? null)
996
+ if (!resourceId) {
997
+ return {
998
+ path: targetPath,
999
+ status: 'noop',
1000
+ reason: 'No server tracked-resource id is recorded for this path. Run `orizu workspace sync` first to register it and record its row id, then retry apply.',
1001
+ manifestUpdated: false,
1002
+ cacheAdvanced: false,
1003
+ }
1004
+ }
1005
+
1006
+ // Metadata-only-pull guard (spec §3.3): a prior `pull` advanced the base to
1007
+ // the server hash without delivering the bytes. If the working content still
1008
+ // doesn't match that pulled base, the working tree is BEHIND and a CAS apply
1009
+ // would pass (base == server truth) and silently revert the pulled version.
1010
+ // Refuse before any server call. Once Git delivers the bytes (working ==
1011
+ // pending base), clear the mark and proceed — a genuine later edit off that
1012
+ // base is then a legitimate, applyable `divergent`.
1013
+ if (snapshot?.contentPending === true) {
1014
+ const pendingBase = stringOrNull(snapshot.pendingBaseSha256 ?? snapshot.baseSha256 ?? null)
1015
+ if (pendingBase !== null && entry.contentSha256 !== pendingBase) {
1016
+ return {
1017
+ path: targetPath,
1018
+ status: 'refused',
1019
+ reason: 'Apply refused: this resource is content-pending — a metadata-only pull advanced its base but the working file still holds the old content. Reconcile the working file to the pulled base first (e.g. `git pull`, or re-pull once Git has delivered the content), then retry apply.',
1020
+ manifestUpdated: false,
1021
+ cacheAdvanced: false,
1022
+ }
1023
+ }
1024
+ // Working content now matches the pulled base — the pull is fully realized.
1025
+ writeCacheSnapshot(root, { ...snapshot, contentPending: false, pendingBaseSha256: null })
1026
+ }
1027
+
1028
+ const workspaceId = requireAttachedWorkspaceId(root, opts)
1029
+ const baseSha = snapshot?.baseSha256 ?? null
1030
+ const response = await fetcher(`/api/cli/workspaces/${workspaceId}/resources/${resourceId}/apply`, {
1031
+ method: 'POST',
1032
+ headers: { 'Content-Type': 'application/json' },
1033
+ body: JSON.stringify({ contentSha256: entry.contentSha256, baseSha256: baseSha }),
1034
+ })
1035
+
1036
+ if (response.status === 409) {
1037
+ // Owner refusal, conflicted, remote-newer, or CAS loss — in every case
1038
+ // the recovery loop is the same: re-sync, converge, retry.
1039
+ const error = await readJson(response)
1040
+ return {
1041
+ path: targetPath,
1042
+ status: 'refused',
1043
+ reason: stringOrNull(error.error) || 'Apply refused',
1044
+ hint: 'Run `orizu workspace sync` to refresh statuses, converge per the reported status (pull/merge), then retry the apply.',
1045
+ manifestUpdated: false,
1046
+ cacheAdvanced: false,
1047
+ }
1048
+ }
1049
+ if (!response.ok) {
1050
+ const error = await readJson(response)
1051
+ throw new Error(`Apply failed: ${stringOrNull(error.error) || response.status}`)
1052
+ }
1053
+
1054
+ const data = await readJson(response)
1055
+ const resource = (data.resource as Record<string, unknown>) ?? {}
1056
+ const versionId = stringOrNull(resource.versionId)
1057
+ const contentSha256 = stringOrNull(resource.contentSha256) ?? entry.contentSha256
1058
+ const lastSyncedAt = stringOrNull(resource.lastSyncedAt) ?? new Date().toISOString()
1059
+
1060
+ if (entry.manifestPath) {
1061
+ patchCanonical(entry.manifestPath, { versionId, contentSha256, lastPulledAt: lastSyncedAt })
1062
+ }
1063
+ writeCacheSnapshot(root, {
1064
+ path: entry.path,
1065
+ baseSha256: contentSha256,
1066
+ versionId,
1067
+ lastSyncedAt,
1068
+ resourceId: stringOrNull(resource.id) ?? resourceId,
1069
+ })
1070
+
1071
+ return {
1072
+ path: targetPath,
1073
+ status: 'applied',
1074
+ versionId,
1075
+ contentSha256,
1076
+ lastSyncedAt,
1077
+ manifestUpdated: Boolean(entry.manifestPath),
1078
+ cacheAdvanced: true,
1079
+ }
1080
+ }
1081
+
1082
+ // ---- Thin entry point (index.ts dispatch calls this) ----------------------
1083
+
1084
+ export interface WorkspaceSyncIo {
1085
+ json: boolean
1086
+ print: (line: string) => void
1087
+ fetcher?: WorkspaceSyncFetcher
1088
+ }
1089
+
1090
+ function emit(io: WorkspaceSyncIo, human: string, payload: Record<string, unknown>) {
1091
+ io.print(io.json ? JSON.stringify(payload) : human)
1092
+ }
1093
+
1094
+ export async function workspaceSyncCommand(args: string[], io: WorkspaceSyncIo): Promise<number> {
1095
+ const positional = args.filter(arg => !arg.startsWith('--'))
1096
+ const subcommand = positional[0]
1097
+ const target = positional[1]
1098
+ const remote = args.includes('--remote')
1099
+ const fetcher = io.fetcher
1100
+
1101
+ if (subcommand === 'status') {
1102
+ const result = await runWorkspaceStatus({ remote, fetcher })
1103
+ const lines = result.resources.map(r =>
1104
+ `${r.dirty ? '*' : ' '} ${r.path} [${r.kind ?? '?'}/${r.owner ?? '?'}/${r.repoState ?? '?'}]`
1105
+ + `${r.localStatus ? ` ${r.localStatus}` : ''}${r.remoteStatus ? ` ${r.remoteStatus}` : ''}`
1106
+ + `${r.contentPending ? ' (content-pending)' : ''}`
1107
+ )
1108
+ if (result.freshness) {
1109
+ const { serverSha, branch, pushedAt } = result.freshness
1110
+ lines.push(
1111
+ `repo moved since your last pull: ${serverSha}`
1112
+ + `${branch ? ` on ${branch}` : ''}${pushedAt ? ` at ${pushedAt}` : ''}`
1113
+ )
1114
+ }
1115
+ if (result.staleSessionBranches && result.staleSessionBranches.length > 0) {
1116
+ lines.push('stale session branches:')
1117
+ for (const stale of result.staleSessionBranches) {
1118
+ const detail = stale.reason === 'active-stale' ? 'active >24h' : 'unmerged'
1119
+ lines.push(` ${stale.branch} (started ${stale.startedAt ?? '?'}, ${detail})`)
1120
+ }
1121
+ }
1122
+ emit(io, ['workspace status', ...lines].join('\n'), result as unknown as Record<string, unknown>)
1123
+ return 0
1124
+ }
1125
+ if (subcommand === 'sync') {
1126
+ const result = await runWorkspaceSync({ fetcher })
1127
+ const lines = result.resources.map(r => `${r.status.padEnd(13)} ${r.path}`)
1128
+ emit(io, [`workspace sync (workspace ${result.workspaceId})`, ...lines].join('\n'), result as unknown as Record<string, unknown>)
1129
+ return 0
1130
+ }
1131
+ if (subcommand === 'pull' && target) {
1132
+ const result = await runWorkspacePull(target, { fetcher })
1133
+ const human = [`${result.status}: ${result.path}${result.reason ? ` — ${result.reason}` : ''}`, result.note].filter(Boolean).join('\n')
1134
+ emit(io, human, result as unknown as Record<string, unknown>)
1135
+ return result.status === 'refused' ? 1 : 0
1136
+ }
1137
+ if (subcommand === 'apply' && target) {
1138
+ const result = await runWorkspaceApply(target, { fetcher })
1139
+ const human = [`${result.status}: ${result.path}${result.reason ? ` — ${result.reason}` : ''}`, result.hint]
1140
+ .filter(Boolean)
1141
+ .join('\n')
1142
+ emit(io, human, result as unknown as Record<string, unknown>)
1143
+ return result.status === 'applied' ? 0 : 1
1144
+ }
1145
+
1146
+ io.print('Usage: orizu workspace <status|sync|pull|apply> [<path>] [--remote] [--json]')
1147
+ return 1
1148
+ }