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.
- package/README.md +22 -13
- package/dist/artifact-archive.js +152 -0
- package/dist/connectors-cli.js +80 -0
- package/dist/credentials.js +4 -1
- package/dist/daytona-slice-rehearsal.js +255 -0
- package/dist/daytona-slice.js +441 -0
- package/dist/egress-allowlist-cli.js +132 -0
- package/dist/egress-policy.js +159 -0
- package/dist/git-credential.js +131 -0
- package/dist/github-setup.js +226 -0
- package/dist/help.js +276 -21
- package/dist/hosted-bootstrap-rehearsal.js +235 -0
- package/dist/hosted-bootstrap.js +365 -0
- package/dist/hosted-broker-server.js +220 -0
- package/dist/hosted-git-http-server.js +136 -0
- package/dist/hosted-harness-claude.js +493 -0
- package/dist/hosted-harness-opencode.js +729 -0
- package/dist/hosted-harness.js +60 -0
- package/dist/hosted-loop.js +477 -0
- package/dist/hosted-run-event-sink.js +499 -0
- package/dist/hosted-runtime-assets.js +216 -0
- package/dist/hosted-session-cli.js +642 -0
- package/dist/index.js +945 -258
- package/dist/kill-switch-cli.js +89 -0
- package/dist/manifests-cli.js +143 -0
- package/dist/preview-runtime.js +4 -1
- package/dist/sandbox-provider.js +238 -0
- package/dist/secret-redaction.js +112 -0
- package/dist/session-timeline.js +103 -0
- package/dist/setup-prompt.js +4 -2
- package/dist/skill-installer.js +9 -1
- package/dist/smoke.js +250 -0
- package/dist/task-assignment-manifest.js +96 -0
- package/dist/vercel-sandbox-provider.js +260 -0
- package/dist/workbench-cli.js +493 -0
- package/dist/workspace-sync.js +924 -0
- package/dist/workspace.js +868 -57
- package/package.json +4 -1
- package/src/artifact-archive.ts +182 -0
- package/src/connectors-cli.ts +115 -0
- package/src/credentials.ts +6 -2
- package/src/daytona-slice-rehearsal.ts +320 -0
- package/src/daytona-slice.ts +598 -0
- package/src/egress-allowlist-cli.ts +147 -0
- package/src/egress-policy.ts +188 -0
- package/src/git-credential.ts +155 -0
- package/src/github-setup.ts +366 -0
- package/src/help.ts +277 -21
- package/src/hosted-bootstrap-rehearsal.ts +312 -0
- package/src/hosted-bootstrap.ts +571 -0
- package/src/hosted-broker-server.ts +278 -0
- package/src/hosted-git-http-server.ts +164 -0
- package/src/hosted-harness-claude.ts +621 -0
- package/src/hosted-harness-opencode.ts +823 -0
- package/src/hosted-harness.ts +124 -0
- package/src/hosted-loop.ts +680 -0
- package/src/hosted-run-event-sink.ts +632 -0
- package/src/hosted-runtime-assets.ts +272 -0
- package/src/hosted-session-cli.ts +844 -0
- package/src/index.ts +1102 -317
- package/src/kill-switch-cli.ts +113 -0
- package/src/manifests-cli.ts +168 -0
- package/src/preview-runtime.ts +14 -9
- package/src/sandbox-provider.ts +440 -0
- package/src/secret-redaction.ts +124 -0
- package/src/session-timeline.ts +152 -0
- package/src/setup-prompt.ts +4 -2
- package/src/skill-installer.ts +13 -1
- package/src/smoke.ts +342 -0
- package/src/task-assignment-manifest.ts +164 -0
- package/src/vercel-sandbox-provider.ts +388 -0
- package/src/workbench-cli.ts +701 -0
- package/src/workspace-sync.ts +1148 -0
- package/src/workspace.ts +1051 -64
- package/vendor/skills/orizu-cli/SKILL.md +8 -4
- 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
|
+
}
|