orizu 0.5.52 → 0.5.53
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/dist/auth-commands.js +2 -2
- package/dist/cli-console-output.d.ts +7 -1
- package/dist/cli-console-output.d.ts.map +1 -1
- package/dist/cli-console-output.js +16 -3
- package/dist/cli-stops.d.ts +14 -0
- package/dist/cli-stops.d.ts.map +1 -0
- package/dist/cli-stops.js +22 -0
- package/dist/egress-allowlist-cli.d.ts +29 -2
- package/dist/egress-allowlist-cli.d.ts.map +1 -1
- package/dist/egress-allowlist-cli.js +16 -89
- package/dist/egress-allowlist-workflow.d.ts +17 -0
- package/dist/egress-allowlist-workflow.d.ts.map +1 -0
- package/dist/egress-allowlist-workflow.js +183 -0
- package/dist/instruction-delivery.d.ts +4 -4
- package/dist/instruction-delivery.d.ts.map +1 -1
- package/dist/instruction-delivery.js +32 -46
- package/dist/instruction-prune.d.ts +24 -0
- package/dist/instruction-prune.d.ts.map +1 -0
- package/dist/instruction-prune.js +33 -0
- package/dist/instruction-set-sync/index.d.ts +0 -2
- package/dist/instruction-set-sync/index.d.ts.map +1 -1
- package/dist/instruction-set-sync/index.js +3 -36
- package/dist/instruction-set-update/index.d.ts +41 -3
- package/dist/instruction-set-update/index.d.ts.map +1 -1
- package/dist/instruction-set-update/index.js +156 -132
- package/dist/instruction-sets-cli.d.ts.map +1 -1
- package/dist/instruction-sets-cli.js +21 -2
- package/dist/instruction-sync-lock.d.ts +9 -0
- package/dist/instruction-sync-lock.d.ts.map +1 -0
- package/dist/instruction-sync-lock.js +24 -0
- package/dist/json-output-scrub.d.ts +3 -0
- package/dist/json-output-scrub.d.ts.map +1 -0
- package/dist/json-output-scrub.js +70 -0
- package/dist/one-request-write.d.ts.map +1 -1
- package/dist/one-request-write.js +2 -9
- package/dist/workbench-cli.d.ts +2 -1
- package/dist/workbench-cli.d.ts.map +1 -1
- package/dist/workbench-cli.js +79 -593
- package/dist/workbench-workflows.d.ts +56 -0
- package/dist/workbench-workflows.d.ts.map +1 -0
- package/dist/workbench-workflows.js +665 -0
- package/dist/workspace-sync-workflow.d.ts +115 -0
- package/dist/workspace-sync-workflow.d.ts.map +1 -0
- package/dist/workspace-sync-workflow.js +642 -0
- package/dist/workspace-sync.d.ts +45 -97
- package/dist/workspace-sync.d.ts.map +1 -1
- package/dist/workspace-sync.js +53 -494
- package/package.json +1 -1
- package/src/auth-commands.ts +2 -2
- package/src/cli-console-output.ts +16 -2
- package/src/cli-stops.ts +27 -0
- package/src/egress-allowlist-cli.ts +18 -90
- package/src/egress-allowlist-workflow.ts +198 -0
- package/src/instruction-delivery.ts +31 -47
- package/src/instruction-prune.ts +59 -0
- package/src/instruction-set-sync/index.ts +3 -43
- package/src/instruction-set-update/index.ts +173 -122
- package/src/instruction-sets-cli.ts +23 -2
- package/src/instruction-sync-lock.ts +27 -0
- package/src/json-output-scrub.ts +73 -0
- package/src/one-request-write.ts +2 -10
- package/src/workbench-cli.ts +83 -684
- package/src/workbench-workflows.ts +815 -0
- package/src/workspace-sync-workflow.ts +838 -0
- package/src/workspace-sync.ts +63 -630
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
// ORI-2762 (ORI-2735a): `orizu workspace` on Effect. Loaded only through
|
|
2
|
+
// `await import(...)` from workspace-sync.ts, so other commands never load
|
|
3
|
+
// Effect.
|
|
4
|
+
//
|
|
5
|
+
// - Each request has the dataset-transfer deadline (120 s by default).
|
|
6
|
+
// - Once a write was sent, a lost or late reply may still be saved. Every
|
|
7
|
+
// workspace write is safe to repeat (attach is keyed by team, sync records
|
|
8
|
+
// the same facts again, apply answers a repeat as a no-op), so the CLI says
|
|
9
|
+
// running the same command again is safe.
|
|
10
|
+
// - Ctrl-C and SIGTERM stop a wait with 130 and 143. Once a reply has
|
|
11
|
+
// arrived, its local writes always finish first.
|
|
12
|
+
// - Each local file is replaced whole (temp file, then rename).
|
|
13
|
+
// - Only one command at a time may change a folder: sync, pull, apply and
|
|
14
|
+
// `status --remote` hold `.orizu/cache/sync.lock`. A lock whose process is
|
|
15
|
+
// gone is taken over.
|
|
16
|
+
import { linkSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { Cause, Context, Effect, Exit } from 'effect';
|
|
19
|
+
import { datasetTransferTimeoutMs } from './dataset-transfer-policy.js';
|
|
20
|
+
import { authedFetch } from './http.js';
|
|
21
|
+
import { sanitizeTerminalText } from './json-response.js';
|
|
22
|
+
import { mayAlreadyBeSavedFields, mayAlreadyBeSavedLine } from './one-request-write.js';
|
|
23
|
+
import { unreachedCode } from './request-outcome.js';
|
|
24
|
+
import { getWorkspaceRoot, WORKSPACE_DIR_NAME } from './workspace.js';
|
|
25
|
+
import { attachedWorkspaceId, cacheResourceIdWrite, cacheSnapshotWrite, canonicalPatchWrite, collectInventory, collectUntrackedPaths, defaultGitRef, missingCachePaths, readCacheSnapshot, stringOrNull, syncBody, teamSlugOf, teamWorkspaceIdWrite, writeFileWhole, } from './workspace-sync.js';
|
|
26
|
+
// ---- Requests -------------------------------------------------------------
|
|
27
|
+
const SAFE_AGAIN = 'running the same command again is safe';
|
|
28
|
+
const ATTACH_NOTE = { thing: 'workspace', done: 'attached' };
|
|
29
|
+
const SYNC_NOTE = { thing: 'workspace sync', done: 'recorded' };
|
|
30
|
+
const APPLY_NOTE = { thing: 'change', done: 'applied' };
|
|
31
|
+
/** A failure the command prints as one message, plus the may-be line when a write may be saved. */
|
|
32
|
+
class WorkspaceFailure {
|
|
33
|
+
message;
|
|
34
|
+
mayBeSaved;
|
|
35
|
+
constructor(message, mayBeSaved = null) {
|
|
36
|
+
this.message = message;
|
|
37
|
+
this.mayBeSaved = mayBeSaved;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Where requests go, their deadline, and the write whose reply is still out. */
|
|
41
|
+
class RequestContext extends Context.Service()('orizu/WorkspaceRequestContext') {
|
|
42
|
+
}
|
|
43
|
+
/** Every local file replacement. Tests swap the rename to make one fail. */
|
|
44
|
+
const LocalFileRename = Context.Reference('orizu/WorkspaceFileRename', {
|
|
45
|
+
defaultValue: () => renameSync,
|
|
46
|
+
});
|
|
47
|
+
const MAX_ERROR_LENGTH = 180;
|
|
48
|
+
/**
|
|
49
|
+
* One request. `label` starts every failure (`Sync failed`); `note` is set
|
|
50
|
+
* for a write, so a reply lost after sending says what may be saved.
|
|
51
|
+
*/
|
|
52
|
+
function request(path, init, label, note) {
|
|
53
|
+
return Effect.gen(function* () {
|
|
54
|
+
const context = yield* RequestContext;
|
|
55
|
+
return yield* Effect.suspend(() => {
|
|
56
|
+
let sent = false;
|
|
57
|
+
let timedOut = false;
|
|
58
|
+
return Effect.tryPromise({
|
|
59
|
+
try: async (signal) => {
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
const abort = () => controller.abort();
|
|
62
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, context.timeoutMs);
|
|
63
|
+
if (signal.aborted)
|
|
64
|
+
abort();
|
|
65
|
+
else
|
|
66
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
67
|
+
const markSent = () => { sent = true; context.inFlight.note = note; };
|
|
68
|
+
try {
|
|
69
|
+
const requestInit = { ...init, signal: controller.signal };
|
|
70
|
+
const response = context.fetcher
|
|
71
|
+
? (markSent(), await context.fetcher(path, requestInit))
|
|
72
|
+
: await authedFetch(path, requestInit, markSent);
|
|
73
|
+
const text = await response.text();
|
|
74
|
+
let data = {};
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(text);
|
|
77
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
78
|
+
data = parsed;
|
|
79
|
+
}
|
|
80
|
+
catch { /* not JSON: read as {} as before */ }
|
|
81
|
+
context.inFlight.note = null;
|
|
82
|
+
return { status: response.status, ok: response.ok, data };
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
signal.removeEventListener('abort', abort);
|
|
87
|
+
controller.abort();
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
catch: cause => {
|
|
91
|
+
const failed = (reason, mayBeSaved) => new WorkspaceFailure(`${label}: ${reason}`, mayBeSaved ? note : null);
|
|
92
|
+
if (timedOut)
|
|
93
|
+
return failed('the request exceeded its deadline', true);
|
|
94
|
+
const code = unreachedCode(cause);
|
|
95
|
+
if (code)
|
|
96
|
+
return failed(`could not reach the server (${code})`, false);
|
|
97
|
+
const reason = sanitizeTerminalText(cause instanceof Error ? cause.message : String(cause));
|
|
98
|
+
// A 401 whose sign-in could not be renewed was refused: nothing saved.
|
|
99
|
+
if (cause?.requestRejected === true)
|
|
100
|
+
return new WorkspaceFailure(reason);
|
|
101
|
+
// Before sending (for example, not signed in) the message stands alone.
|
|
102
|
+
return sent ? failed(reason, true) : new WorkspaceFailure(reason);
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/** A non-2xx reply as today's `<label>: <error or status>`; a 5xx after a write may be saved. */
|
|
109
|
+
function failUnlessOk(reply, label, note) {
|
|
110
|
+
if (reply.ok)
|
|
111
|
+
return Effect.succeed(reply);
|
|
112
|
+
const reason = stringOrNull(reply.data.error);
|
|
113
|
+
const text = reason ? sanitizeTerminalText(reason).slice(0, MAX_ERROR_LENGTH) : String(reply.status);
|
|
114
|
+
const refused = reply.status >= 400 && reply.status < 500;
|
|
115
|
+
return Effect.fail(new WorkspaceFailure(`${label}: ${text}`, refused ? null : note));
|
|
116
|
+
}
|
|
117
|
+
const post = (path, body, label, note) => request(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, label, note);
|
|
118
|
+
// ---- Local writes ---------------------------------------------------------
|
|
119
|
+
/** Replaces each file whole, in order. */
|
|
120
|
+
function writeLocalFiles(writes) {
|
|
121
|
+
return Effect.gen(function* () {
|
|
122
|
+
const rename = yield* LocalFileRename;
|
|
123
|
+
for (const write of writes) {
|
|
124
|
+
if (!write)
|
|
125
|
+
continue;
|
|
126
|
+
yield* Effect.try({
|
|
127
|
+
try: () => writeFileWhole(write, rename),
|
|
128
|
+
catch: cause => new WorkspaceFailure(`Could not write ${write.path}: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Sends `send`, then writes what its reply calls for. Only the wait can be
|
|
135
|
+
* interrupted: once the reply is in, its writes all finish, so Ctrl-C never
|
|
136
|
+
* leaves some files written and others not.
|
|
137
|
+
*/
|
|
138
|
+
function thenWrite(send, writes) {
|
|
139
|
+
return Effect.uninterruptibleMask(restore => restore(send).pipe(Effect.tap(reply => writeLocalFiles(writes(reply)))));
|
|
140
|
+
}
|
|
141
|
+
// ---- Folder lock ----------------------------------------------------------
|
|
142
|
+
const lockPath = (root) => join(root, WORKSPACE_DIR_NAME, 'cache', 'sync.lock');
|
|
143
|
+
function processAlive(pid) {
|
|
144
|
+
try {
|
|
145
|
+
process.kill(pid, 0);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
// EPERM: alive, owned by someone else.
|
|
150
|
+
return error.code === 'EPERM';
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const lockHolder = (path) => {
|
|
154
|
+
try {
|
|
155
|
+
const pid = Number(readFileSync(path, 'utf8').trim());
|
|
156
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
// The pid is written to a temp file first and linked into place, so the lock
|
|
163
|
+
// file, once it exists, always holds a whole pid.
|
|
164
|
+
function takeLock(root) {
|
|
165
|
+
return Effect.try({
|
|
166
|
+
try: () => {
|
|
167
|
+
const path = lockPath(root);
|
|
168
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
169
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
170
|
+
writeFileSync(temp, `${process.pid}\n`);
|
|
171
|
+
try {
|
|
172
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
173
|
+
try {
|
|
174
|
+
linkSync(temp, path);
|
|
175
|
+
return path;
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error.code !== 'EEXIST')
|
|
179
|
+
throw error;
|
|
180
|
+
const holder = lockHolder(path);
|
|
181
|
+
if (holder !== null && processAlive(holder)) {
|
|
182
|
+
throw new WorkspaceFailure(`Another orizu workspace command is running in this folder (pid ${holder}); wait for it to finish.`);
|
|
183
|
+
}
|
|
184
|
+
// Left by a process that is gone: take it over.
|
|
185
|
+
rmSync(path, { force: true });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
throw new WorkspaceFailure('Another orizu workspace command is starting in this folder; wait for it to finish.');
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
rmSync(temp, { force: true });
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
catch: cause => cause instanceof WorkspaceFailure ? cause : new WorkspaceFailure(`Could not lock this folder: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
// Removed only while it still names this process.
|
|
198
|
+
const releaseLock = (path) => Effect.sync(() => {
|
|
199
|
+
if (lockHolder(path) === process.pid)
|
|
200
|
+
rmSync(path, { force: true });
|
|
201
|
+
});
|
|
202
|
+
const withFolderLock = (root, program) => Effect.scoped(Effect.acquireRelease(takeLock(root), releaseLock).pipe(Effect.andThen(program)));
|
|
203
|
+
// Create-or-attach the server workspace. Deliberately does NOT write the
|
|
204
|
+
// manifest: the caller persists the id only after the first sync round-trip
|
|
205
|
+
// succeeds, so a failed sync never leaves a partially-attached manifest.
|
|
206
|
+
function createOrAttachWorkspace(root) {
|
|
207
|
+
return Effect.gen(function* () {
|
|
208
|
+
const teamSlug = teamSlugOf(root);
|
|
209
|
+
const label = 'Failed to attach workspace';
|
|
210
|
+
const reply = yield* post('/api/cli/workspaces', { teamSlug, name: teamSlug, slug: teamSlug }, label, ATTACH_NOTE).pipe(Effect.flatMap(answer => failUnlessOk(answer, label, ATTACH_NOTE)));
|
|
211
|
+
const workspace = reply.data.workspace;
|
|
212
|
+
const id = stringOrNull(workspace?.id);
|
|
213
|
+
if (!id)
|
|
214
|
+
return yield* Effect.fail(new WorkspaceFailure('Workspace attach response did not include an id', ATTACH_NOTE));
|
|
215
|
+
return id;
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
function requireAttachedWorkspaceId(root, opts) {
|
|
219
|
+
const known = stringOrNull(opts.workspaceId ?? undefined) || attachedWorkspaceId(root);
|
|
220
|
+
return known
|
|
221
|
+
? Effect.succeed(known)
|
|
222
|
+
: Effect.fail(new WorkspaceFailure('This workspace is not attached yet. Run `orizu workspace sync` first to attach it.'));
|
|
223
|
+
}
|
|
224
|
+
function callSync(root, workspaceId, entries) {
|
|
225
|
+
const label = 'Sync failed';
|
|
226
|
+
return post(`/api/cli/workspaces/${workspaceId}/sync`, syncBody(root, entries), label, SYNC_NOTE).pipe(Effect.flatMap(reply => failUnlessOk(reply, label, SYNC_NOTE)), Effect.map(reply => (Array.isArray(reply.data.resources) ? reply.data.resources : [])));
|
|
227
|
+
}
|
|
228
|
+
const STALE_ACTIVE_MS = 24 * 60 * 60 * 1000;
|
|
229
|
+
// A best-effort read: any failure, the deadline included, reads as nothing.
|
|
230
|
+
const readBestEffort = (path) => request(path, { method: 'GET' }, 'Read failed', null).pipe(Effect.map(reply => reply.ok ? reply.data : null), Effect.catch(() => Effect.succeed(null)));
|
|
231
|
+
// Fetch the workspace record and compare its recorded DEFAULT-branch push SHA
|
|
232
|
+
// against what the local repo knows that same branch to be. Best-effort: any
|
|
233
|
+
// server/non-hosted/non-git/unknown-branch condition yields null so `status`
|
|
234
|
+
// never fails — and never false-warns — for a local-only, offline, or
|
|
235
|
+
// feature-branch checkout.
|
|
236
|
+
function fetchFreshness(root, workspaceId, gitRef) {
|
|
237
|
+
return readBestEffort(`/api/cli/workspaces?team=${encodeURIComponent(teamSlugOf(root))}`).pipe(Effect.map((data) => {
|
|
238
|
+
if (!data)
|
|
239
|
+
return null;
|
|
240
|
+
const workspaces = Array.isArray(data.workspaces) ? data.workspaces : [];
|
|
241
|
+
const ws = workspaces.find(item => stringOrNull(item.id) === workspaceId) ?? null;
|
|
242
|
+
const serverSha = ws ? stringOrNull(ws.repoLastPushSha) : null;
|
|
243
|
+
const branch = ws ? stringOrNull(ws.repoLastPushBranch) : null;
|
|
244
|
+
// Without the server's default-branch name we cannot locate the right local
|
|
245
|
+
// ref to compare — stay silent rather than compare against the wrong branch.
|
|
246
|
+
if (!serverSha || !branch)
|
|
247
|
+
return null;
|
|
248
|
+
// Prefer the remote-tracking ref (what the last fetch/pull observed on the
|
|
249
|
+
// default branch); fall back to the local branch. HEAD is deliberately NOT
|
|
250
|
+
// used: a feature-branch checkout must never read as "repo moved".
|
|
251
|
+
const localDefaultSha = gitRef(root, `origin/${branch}`) ?? gitRef(root, branch);
|
|
252
|
+
if (!localDefaultSha || localDefaultSha === serverSha)
|
|
253
|
+
return null;
|
|
254
|
+
return { serverSha, branch, pushedAt: stringOrNull(ws?.repoLastPushAt ?? null), localDefaultSha };
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
// List the workspace's branched sessions and flag the stale ones. Report,
|
|
258
|
+
// don't reap (ALI-972): active sessions still holding a branch past 24h, and
|
|
259
|
+
// ended sessions whose branch metadata persists (unmerged write-back).
|
|
260
|
+
function fetchStaleSessionBranches(workspaceId, now) {
|
|
261
|
+
return readBestEffort(`/api/cli/workspaces/${workspaceId}/sessions?branched=true`).pipe(Effect.map(data => {
|
|
262
|
+
const sessions = data && Array.isArray(data.sessions) ? data.sessions : [];
|
|
263
|
+
const stale = [];
|
|
264
|
+
for (const session of sessions) {
|
|
265
|
+
// Artifacts uses compatibility repoBranch=main for its separate private
|
|
266
|
+
// working repository. It is never a stale branch in the caller's legacy
|
|
267
|
+
// GitHub workbench checkout.
|
|
268
|
+
if (stringOrNull(session.workingRepositoryId))
|
|
269
|
+
continue;
|
|
270
|
+
const branch = stringOrNull(session.repoBranch);
|
|
271
|
+
if (!branch)
|
|
272
|
+
continue;
|
|
273
|
+
const status = stringOrNull(session.status);
|
|
274
|
+
const startedAt = stringOrNull(session.startedAt);
|
|
275
|
+
const sessionId = stringOrNull(session.id) ?? '';
|
|
276
|
+
if (status === 'active') {
|
|
277
|
+
const started = startedAt ? Date.parse(startedAt) : NaN;
|
|
278
|
+
if (!Number.isNaN(started) && now - started > STALE_ACTIVE_MS) {
|
|
279
|
+
stale.push({ sessionId, branch, startedAt, reason: 'active-stale' });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
else if (status === 'ended') {
|
|
283
|
+
stale.push({ sessionId, branch, startedAt, reason: 'unmerged' });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return stale;
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
function workspaceStatus(opts) {
|
|
290
|
+
return Effect.gen(function* () {
|
|
291
|
+
const root = getWorkspaceRoot(opts.cwd);
|
|
292
|
+
const inventory = collectInventory(root);
|
|
293
|
+
const lines = inventory.map(entry => {
|
|
294
|
+
const snapshot = readCacheSnapshot(root, entry.path);
|
|
295
|
+
const base = snapshot?.baseSha256 ?? null;
|
|
296
|
+
// Surface the §3.3 guard only while it still bites: if the working content
|
|
297
|
+
// has since reconciled to the pending base, apply would clear it and proceed.
|
|
298
|
+
const pendingBase = snapshot?.contentPending === true
|
|
299
|
+
? stringOrNull(snapshot.pendingBaseSha256 ?? snapshot.baseSha256 ?? null)
|
|
300
|
+
: null;
|
|
301
|
+
const contentPending = pendingBase !== null && pendingBase !== entry.contentSha256;
|
|
302
|
+
return {
|
|
303
|
+
path: entry.path,
|
|
304
|
+
kind: entry.kind,
|
|
305
|
+
owner: entry.owner,
|
|
306
|
+
repoState: entry.repoState,
|
|
307
|
+
localSha: entry.contentSha256,
|
|
308
|
+
baseSha: base,
|
|
309
|
+
dirty: base !== null && base !== entry.contentSha256,
|
|
310
|
+
...(contentPending ? { contentPending: true } : {}),
|
|
311
|
+
};
|
|
312
|
+
});
|
|
313
|
+
// Cache-known paths that no longer exist on disk (spec §3.2 `missing`).
|
|
314
|
+
for (const path of missingCachePaths(root, inventory)) {
|
|
315
|
+
lines.push({ path, kind: null, owner: null, repoState: null, localSha: null, baseSha: readCacheSnapshot(root, path)?.baseSha256 ?? null, dirty: false, localStatus: 'missing' });
|
|
316
|
+
}
|
|
317
|
+
// Rule-less files under managed dirs (spec §3.2 `untracked`) — local only.
|
|
318
|
+
for (const path of collectUntrackedPaths(root, inventory)) {
|
|
319
|
+
lines.push({ path, kind: null, owner: null, repoState: null, localSha: null, baseSha: null, dirty: false, localStatus: 'untracked' });
|
|
320
|
+
}
|
|
321
|
+
let workspaceId = opts.workspaceId ?? attachedWorkspaceId(root);
|
|
322
|
+
if (opts.remote) {
|
|
323
|
+
// No read-only remote status exists in v0 (spec §3.1): this is the same
|
|
324
|
+
// server-side reconciliation as `sync` (it registers/updates records).
|
|
325
|
+
// It never writes the local cache, though.
|
|
326
|
+
workspaceId = yield* requireAttachedWorkspaceId(root, opts);
|
|
327
|
+
const remote = yield* callSync(root, workspaceId, inventory);
|
|
328
|
+
const byPath = new Map(remote.map(item => [item.path, item]));
|
|
329
|
+
for (const line of lines) {
|
|
330
|
+
const match = byPath.get(line.path);
|
|
331
|
+
if (match) {
|
|
332
|
+
line.remoteStatus = match.status;
|
|
333
|
+
line.serverVersionId = match.serverVersionId;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// Hosted-repo freshness + stale-branch report (best-effort): only for attached
|
|
338
|
+
// workspaces, and every server/non-git failure is swallowed so a local-only or
|
|
339
|
+
// offline `status` behaves exactly as before.
|
|
340
|
+
let freshness = null;
|
|
341
|
+
let staleSessionBranches = [];
|
|
342
|
+
if (workspaceId) {
|
|
343
|
+
const now = (opts.now ?? (() => Date.now()))();
|
|
344
|
+
freshness = yield* fetchFreshness(root, workspaceId, opts.gitRef ?? defaultGitRef);
|
|
345
|
+
staleSessionBranches = yield* fetchStaleSessionBranches(workspaceId, now);
|
|
346
|
+
}
|
|
347
|
+
return { root, workspaceId, remote: Boolean(opts.remote), resources: lines, freshness, staleSessionBranches };
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
function workspaceSync(opts) {
|
|
351
|
+
return Effect.gen(function* () {
|
|
352
|
+
const root = getWorkspaceRoot(opts.cwd);
|
|
353
|
+
const knownId = stringOrNull(opts.workspaceId ?? undefined) || attachedWorkspaceId(root);
|
|
354
|
+
const workspaceId = knownId ?? (yield* createOrAttachWorkspace(root));
|
|
355
|
+
const inventory = collectInventory(root);
|
|
356
|
+
const resources = [];
|
|
357
|
+
yield* thenWrite(callSync(root, workspaceId, inventory), remote => {
|
|
358
|
+
const writes = [];
|
|
359
|
+
for (const item of remote) {
|
|
360
|
+
// Cache base advances ONLY on a `clean` convergence confirmation; every
|
|
361
|
+
// other status leaves the last-sync base untouched (spec §3.1 metadata-first).
|
|
362
|
+
// The server row id, however, is recorded for EVERY returned resource —
|
|
363
|
+
// it is what `apply` addresses, so losing it would strand new resources.
|
|
364
|
+
const resourceId = stringOrNull(item.id) ?? readCacheSnapshot(root, item.path)?.resourceId ?? null;
|
|
365
|
+
const clean = item.status === 'clean';
|
|
366
|
+
writes.push(clean
|
|
367
|
+
? cacheSnapshotWrite(root, { path: item.path, baseSha256: item.serverSha256, versionId: item.serverVersionId, lastSyncedAt: item.lastSyncedAt, resourceId })
|
|
368
|
+
: cacheResourceIdWrite(root, item.path, resourceId));
|
|
369
|
+
resources.push({ ...item, cacheAdvanced: clean });
|
|
370
|
+
}
|
|
371
|
+
// Persist the attachment only after the sync round-trip succeeded, so a
|
|
372
|
+
// failed first sync leaves orizu.team.json byte-identical.
|
|
373
|
+
if (!knownId)
|
|
374
|
+
writes.push(teamWorkspaceIdWrite(root, workspaceId));
|
|
375
|
+
return writes;
|
|
376
|
+
});
|
|
377
|
+
return { root, workspaceId, resources };
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
function workspacePull(targetPath, opts) {
|
|
381
|
+
return Effect.gen(function* () {
|
|
382
|
+
const root = getWorkspaceRoot(opts.cwd);
|
|
383
|
+
const entry = collectInventory(root).find(item => item.path === targetPath);
|
|
384
|
+
if (!entry) {
|
|
385
|
+
const known = readCacheSnapshot(root, targetPath);
|
|
386
|
+
return {
|
|
387
|
+
path: targetPath,
|
|
388
|
+
status: 'noop',
|
|
389
|
+
remoteStatus: null,
|
|
390
|
+
reason: known
|
|
391
|
+
? `File is missing locally at ${targetPath}. Restore it via Git; v0 pull reconciles metadata only and does not materialize content.`
|
|
392
|
+
: `No tracked resource at ${targetPath}`,
|
|
393
|
+
manifestUpdated: false,
|
|
394
|
+
cacheAdvanced: false,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
const workspaceId = yield* requireAttachedWorkspaceId(root, opts);
|
|
398
|
+
let result = null;
|
|
399
|
+
yield* thenWrite(callSync(root, workspaceId, [entry]), results => {
|
|
400
|
+
const remote = results.find(item => item.path === entry.path) ?? null;
|
|
401
|
+
const remoteStatus = remote?.status ?? null;
|
|
402
|
+
if (remoteStatus === 'divergent' || remoteStatus === 'conflicted') {
|
|
403
|
+
result = {
|
|
404
|
+
path: targetPath,
|
|
405
|
+
status: 'refused',
|
|
406
|
+
remoteStatus,
|
|
407
|
+
reason: 'Pull refused: local edits are ahead of the last-sync base; resolve in the working tree (Git) or `apply` to promote.',
|
|
408
|
+
manifestUpdated: false,
|
|
409
|
+
cacheAdvanced: false,
|
|
410
|
+
};
|
|
411
|
+
return [];
|
|
412
|
+
}
|
|
413
|
+
// Fast-forward ONLY from server-carried truth. Truthless statuses
|
|
414
|
+
// (local-only/stale/missing/untracked) have nothing to pull; fabricating a
|
|
415
|
+
// base from local bytes is forbidden (spec §3.2).
|
|
416
|
+
const serverSha256 = stringOrNull(remote?.serverSha256 ?? null);
|
|
417
|
+
const serverVersionId = stringOrNull(remote?.serverVersionId ?? null);
|
|
418
|
+
if (!remote || serverSha256 === null) {
|
|
419
|
+
const reason = remoteStatus === 'stale'
|
|
420
|
+
? '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.'
|
|
421
|
+
: '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.';
|
|
422
|
+
result = { path: targetPath, status: 'noop', remoteStatus, reason, manifestUpdated: false, cacheAdvanced: false };
|
|
423
|
+
return [];
|
|
424
|
+
}
|
|
425
|
+
// Metadata fast-forward: advance cache base + manifest canonical to the
|
|
426
|
+
// SERVER truth (never local bytes). v0 is metadata-level: source bytes
|
|
427
|
+
// converge through Git; DB-native/object bytes materialize via the
|
|
428
|
+
// primitive-specific commands.
|
|
429
|
+
//
|
|
430
|
+
// Metadata-only-pull guard (spec §3.3): this pull advanced the base to the
|
|
431
|
+
// server hash WITHOUT delivering the bytes. For a repo-owned resource whose
|
|
432
|
+
// working content does not already match that hash, the working tree is now
|
|
433
|
+
// BEHIND the base — a naive three-way read would call it `divergent` and a
|
|
434
|
+
// CAS `apply` would pass (base == server truth) and silently revert the
|
|
435
|
+
// pulled version. Mark the resource content-pending so `apply` refuses until
|
|
436
|
+
// Git delivers the bytes (working == base again). If the content already
|
|
437
|
+
// matches (Git first, the §7 happy path), leave it unmarked.
|
|
438
|
+
const contentPending = entry.owner === 'repo' && entry.contentSha256 !== serverSha256;
|
|
439
|
+
const syncedAt = remote.lastSyncedAt ?? new Date().toISOString();
|
|
440
|
+
const note = contentPending
|
|
441
|
+
? '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.'
|
|
442
|
+
: entry.owner === 'repo'
|
|
443
|
+
? undefined
|
|
444
|
+
: 'v0 pull reconciles metadata only; materialize bulk content with the primitive-specific command (e.g. `orizu prompts pull`, `orizu datasets download`).';
|
|
445
|
+
result = { path: targetPath, status: 'converged', remoteStatus, note, manifestUpdated: Boolean(entry.manifestPath), cacheAdvanced: true };
|
|
446
|
+
return [
|
|
447
|
+
cacheSnapshotWrite(root, {
|
|
448
|
+
path: entry.path,
|
|
449
|
+
baseSha256: serverSha256,
|
|
450
|
+
versionId: serverVersionId,
|
|
451
|
+
lastSyncedAt: syncedAt,
|
|
452
|
+
resourceId: stringOrNull(remote.id) ?? readCacheSnapshot(root, entry.path)?.resourceId ?? null,
|
|
453
|
+
...(contentPending ? { contentPending: true, pendingBaseSha256: serverSha256 } : {}),
|
|
454
|
+
}),
|
|
455
|
+
entry.manifestPath
|
|
456
|
+
? canonicalPatchWrite(entry.manifestPath, { versionId: serverVersionId, contentSha256: serverSha256, lastPulledAt: syncedAt })
|
|
457
|
+
: null,
|
|
458
|
+
];
|
|
459
|
+
});
|
|
460
|
+
return result;
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
function workspaceApply(targetPath, opts) {
|
|
464
|
+
return Effect.gen(function* () {
|
|
465
|
+
const root = getWorkspaceRoot(opts.cwd);
|
|
466
|
+
const entry = collectInventory(root).find(item => item.path === targetPath);
|
|
467
|
+
if (!entry) {
|
|
468
|
+
return { path: targetPath, status: 'noop', reason: `No tracked resource at ${targetPath}`, manifestUpdated: false, cacheAdvanced: false };
|
|
469
|
+
}
|
|
470
|
+
if (entry.owner !== 'repo') {
|
|
471
|
+
return {
|
|
472
|
+
path: targetPath,
|
|
473
|
+
status: 'refused',
|
|
474
|
+
reason: `Apply refused: ${entry.owner} resources are pull-only; change DB-native state through the primitive's own command and promotion flow.`,
|
|
475
|
+
manifestUpdated: false,
|
|
476
|
+
cacheAdvanced: false,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
// Apply identity (spec §3.1): STRICTLY the sync-recorded tracked-resource
|
|
480
|
+
// row id. The manifest's canonical.serviceId is informational/legacy and is
|
|
481
|
+
// never used — the API resolves by row id only, so a serviceId would 404.
|
|
482
|
+
const snapshot = readCacheSnapshot(root, entry.path);
|
|
483
|
+
const resourceId = stringOrNull(snapshot?.resourceId ?? null);
|
|
484
|
+
if (!resourceId) {
|
|
485
|
+
return {
|
|
486
|
+
path: targetPath,
|
|
487
|
+
status: 'noop',
|
|
488
|
+
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.',
|
|
489
|
+
manifestUpdated: false,
|
|
490
|
+
cacheAdvanced: false,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
// Metadata-only-pull guard (spec §3.3): a prior `pull` advanced the base to
|
|
494
|
+
// the server hash without delivering the bytes. If the working content still
|
|
495
|
+
// doesn't match that pulled base, the working tree is BEHIND and a CAS apply
|
|
496
|
+
// would pass (base == server truth) and silently revert the pulled version.
|
|
497
|
+
// Refuse before any server call. Once Git delivers the bytes (working ==
|
|
498
|
+
// pending base), clear the mark and proceed — a genuine later edit off that
|
|
499
|
+
// base is then a legitimate, applyable `divergent`.
|
|
500
|
+
if (snapshot?.contentPending === true) {
|
|
501
|
+
const pendingBase = stringOrNull(snapshot.pendingBaseSha256 ?? snapshot.baseSha256 ?? null);
|
|
502
|
+
if (pendingBase !== null && entry.contentSha256 !== pendingBase) {
|
|
503
|
+
return {
|
|
504
|
+
path: targetPath,
|
|
505
|
+
status: 'refused',
|
|
506
|
+
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.',
|
|
507
|
+
manifestUpdated: false,
|
|
508
|
+
cacheAdvanced: false,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
// Working content now matches the pulled base — the pull is fully realized.
|
|
512
|
+
yield* writeLocalFiles([cacheSnapshotWrite(root, { ...snapshot, contentPending: false, pendingBaseSha256: null })]);
|
|
513
|
+
}
|
|
514
|
+
const workspaceId = yield* requireAttachedWorkspaceId(root, opts);
|
|
515
|
+
const baseSha = snapshot?.baseSha256 ?? null;
|
|
516
|
+
const label = 'Apply failed';
|
|
517
|
+
let result = null;
|
|
518
|
+
yield* thenWrite(post(`/api/cli/workspaces/${workspaceId}/resources/${resourceId}/apply`, { contentSha256: entry.contentSha256, baseSha256: baseSha }, label, APPLY_NOTE).pipe(Effect.flatMap(reply => reply.status === 409 ? Effect.succeed(reply) : failUnlessOk(reply, label, APPLY_NOTE))), reply => {
|
|
519
|
+
if (reply.status === 409) {
|
|
520
|
+
// Owner refusal, conflicted, remote-newer, or CAS loss — in every case
|
|
521
|
+
// the recovery loop is the same: re-sync, converge, retry.
|
|
522
|
+
result = {
|
|
523
|
+
path: targetPath,
|
|
524
|
+
status: 'refused',
|
|
525
|
+
reason: stringOrNull(reply.data.error) || 'Apply refused',
|
|
526
|
+
hint: 'Run `orizu workspace sync` to refresh statuses, converge per the reported status (pull/merge), then retry the apply.',
|
|
527
|
+
manifestUpdated: false,
|
|
528
|
+
cacheAdvanced: false,
|
|
529
|
+
};
|
|
530
|
+
return [];
|
|
531
|
+
}
|
|
532
|
+
const resource = reply.data.resource ?? {};
|
|
533
|
+
const versionId = stringOrNull(resource.versionId);
|
|
534
|
+
const contentSha256 = stringOrNull(resource.contentSha256) ?? entry.contentSha256;
|
|
535
|
+
const lastSyncedAt = stringOrNull(resource.lastSyncedAt) ?? new Date().toISOString();
|
|
536
|
+
result = { path: targetPath, status: 'applied', versionId, contentSha256, lastSyncedAt, manifestUpdated: Boolean(entry.manifestPath), cacheAdvanced: true };
|
|
537
|
+
return [
|
|
538
|
+
entry.manifestPath ? canonicalPatchWrite(entry.manifestPath, { versionId, contentSha256, lastPulledAt: lastSyncedAt }) : null,
|
|
539
|
+
cacheSnapshotWrite(root, { path: entry.path, baseSha256: contentSha256, versionId, lastSyncedAt, resourceId: stringOrNull(resource.id) ?? resourceId }),
|
|
540
|
+
];
|
|
541
|
+
});
|
|
542
|
+
return result;
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
/** Runs one command program to a value, or throws its failure as an Error. */
|
|
546
|
+
async function runToPromise(program, opts, inFlight = { note: null }) {
|
|
547
|
+
const provided = program.pipe(Effect.provideService(RequestContext, { fetcher: opts.fetcher, timeoutMs: datasetTransferTimeoutMs(), inFlight }), opts.rename ? Effect.provideService(LocalFileRename, opts.rename) : effect => effect);
|
|
548
|
+
const exit = await Effect.runPromiseExit(provided, opts.signal ? { signal: opts.signal } : undefined);
|
|
549
|
+
if (Exit.isSuccess(exit))
|
|
550
|
+
return exit.value;
|
|
551
|
+
if (opts.signal?.aborted && Cause.hasInterruptsOnly(exit.cause))
|
|
552
|
+
throw new WorkspaceInterrupted();
|
|
553
|
+
const failure = Cause.squash(exit.cause);
|
|
554
|
+
if (!(failure instanceof WorkspaceFailure))
|
|
555
|
+
throw failure;
|
|
556
|
+
throw new Error(failure.mayBeSaved ? `${failure.message}\n${mayAlreadyBeSavedLine(failure.mayBeSaved.thing, failure.mayBeSaved.done, SAFE_AGAIN)}` : failure.message);
|
|
557
|
+
}
|
|
558
|
+
class WorkspaceInterrupted extends Error {
|
|
559
|
+
constructor() { super('interrupted'); }
|
|
560
|
+
}
|
|
561
|
+
const lockedIn = (opts, program) => withFolderLock(getWorkspaceRoot(opts.cwd), program);
|
|
562
|
+
export const runWorkspaceStatus = (opts = {}) => runToPromise(opts.remote ? lockedIn(opts, workspaceStatus(opts)) : workspaceStatus(opts), opts);
|
|
563
|
+
export const runWorkspaceSync = (opts = {}) => runToPromise(lockedIn(opts, workspaceSync(opts)), opts);
|
|
564
|
+
export const runWorkspacePull = (targetPath, opts = {}) => runToPromise(lockedIn(opts, workspacePull(targetPath, opts)), opts);
|
|
565
|
+
export const runWorkspaceApply = (targetPath, opts = {}) => runToPromise(lockedIn(opts, workspaceApply(targetPath, opts)), opts);
|
|
566
|
+
function emit(io, human, payload) {
|
|
567
|
+
io.print(io.json ? JSON.stringify(payload) : human);
|
|
568
|
+
}
|
|
569
|
+
const COMMAND_NAMES = { status: 'Workspace status', sync: 'Workspace sync', pull: 'Workspace pull', apply: 'Workspace apply' };
|
|
570
|
+
/**
|
|
571
|
+
* Runs one `orizu workspace` command. Ctrl-C or SIGTERM ends the wait with
|
|
572
|
+
* 130 or 143, saying what may be saved when a write's reply was still out.
|
|
573
|
+
*/
|
|
574
|
+
export async function runWorkspaceCommand(command, io) {
|
|
575
|
+
const controller = new AbortController();
|
|
576
|
+
let interrupted;
|
|
577
|
+
const interrupt = () => { interrupted ??= 130; controller.abort(); };
|
|
578
|
+
const terminate = () => { interrupted ??= 143; controller.abort(); };
|
|
579
|
+
const inFlight = { note: null };
|
|
580
|
+
const opts = { fetcher: io.fetcher, signal: controller.signal };
|
|
581
|
+
process.on('SIGINT', interrupt);
|
|
582
|
+
process.on('SIGTERM', terminate);
|
|
583
|
+
try {
|
|
584
|
+
return await printed(command, io, opts, inFlight);
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
if (!(error instanceof WorkspaceInterrupted) || interrupted === undefined)
|
|
588
|
+
throw error;
|
|
589
|
+
const message = `${COMMAND_NAMES[command.subcommand]} interrupted.`;
|
|
590
|
+
const note = inFlight.note;
|
|
591
|
+
if (io.json)
|
|
592
|
+
io.print(JSON.stringify({ error: message, ...(note ? mayAlreadyBeSavedFields(SAFE_AGAIN) : {}) }));
|
|
593
|
+
process.stderr.write(note ? `${message}\n${mayAlreadyBeSavedLine(note.thing, note.done, SAFE_AGAIN)}\n` : `${message}\n`);
|
|
594
|
+
return interrupted;
|
|
595
|
+
}
|
|
596
|
+
finally {
|
|
597
|
+
process.removeListener('SIGINT', interrupt);
|
|
598
|
+
process.removeListener('SIGTERM', terminate);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
async function printed(command, io, opts, inFlight) {
|
|
602
|
+
const { subcommand, target, remote } = command;
|
|
603
|
+
if (subcommand === 'status') {
|
|
604
|
+
const status = workspaceStatus({ ...opts, remote });
|
|
605
|
+
const result = await runToPromise(remote ? lockedIn(opts, status) : status, opts, inFlight);
|
|
606
|
+
const lines = result.resources.map(r => `${r.dirty ? '*' : ' '} ${r.path} [${r.kind ?? '?'}/${r.owner ?? '?'}/${r.repoState ?? '?'}]`
|
|
607
|
+
+ `${r.localStatus ? ` ${r.localStatus}` : ''}${r.remoteStatus ? ` ${r.remoteStatus}` : ''}`
|
|
608
|
+
+ `${r.contentPending ? ' (content-pending)' : ''}`);
|
|
609
|
+
if (result.freshness) {
|
|
610
|
+
const { serverSha, branch, pushedAt } = result.freshness;
|
|
611
|
+
lines.push(`repo moved since your last pull: ${serverSha}`
|
|
612
|
+
+ `${branch ? ` on ${branch}` : ''}${pushedAt ? ` at ${pushedAt}` : ''}`);
|
|
613
|
+
}
|
|
614
|
+
if (result.staleSessionBranches && result.staleSessionBranches.length > 0) {
|
|
615
|
+
lines.push('stale session branches:');
|
|
616
|
+
for (const stale of result.staleSessionBranches) {
|
|
617
|
+
const detail = stale.reason === 'active-stale' ? 'active >24h' : 'unmerged';
|
|
618
|
+
lines.push(` ${stale.branch} (started ${stale.startedAt ?? '?'}, ${detail})`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
emit(io, ['workspace status', ...lines].join('\n'), result);
|
|
622
|
+
return 0;
|
|
623
|
+
}
|
|
624
|
+
if (subcommand === 'sync') {
|
|
625
|
+
const result = await runToPromise(lockedIn(opts, workspaceSync(opts)), opts, inFlight);
|
|
626
|
+
const lines = result.resources.map(r => `${r.status.padEnd(13)} ${r.path}`);
|
|
627
|
+
emit(io, [`workspace sync (workspace ${result.workspaceId})`, ...lines].join('\n'), result);
|
|
628
|
+
return 0;
|
|
629
|
+
}
|
|
630
|
+
if (subcommand === 'pull') {
|
|
631
|
+
const result = await runToPromise(lockedIn(opts, workspacePull(target, opts)), opts, inFlight);
|
|
632
|
+
const human = [`${result.status}: ${result.path}${result.reason ? ` — ${result.reason}` : ''}`, result.note].filter(Boolean).join('\n');
|
|
633
|
+
emit(io, human, result);
|
|
634
|
+
return result.status === 'refused' ? 1 : 0;
|
|
635
|
+
}
|
|
636
|
+
const result = await runToPromise(lockedIn(opts, workspaceApply(target, opts)), opts, inFlight);
|
|
637
|
+
const human = [`${result.status}: ${result.path}${result.reason ? ` — ${result.reason}` : ''}`, result.hint]
|
|
638
|
+
.filter(Boolean)
|
|
639
|
+
.join('\n');
|
|
640
|
+
emit(io, human, result);
|
|
641
|
+
return result.status === 'applied' ? 0 : 1;
|
|
642
|
+
}
|