orizu 0.5.38 → 0.5.39
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 +18 -3
- package/dist/app-commands.d.ts.map +1 -1
- package/dist/app-commands.js +215 -57
- package/dist/app-publish-failure.d.ts +6 -0
- package/dist/app-publish-failure.d.ts.map +1 -0
- package/dist/app-publish-failure.js +9 -0
- package/dist/app-publish-paths.d.ts +4 -0
- package/dist/app-publish-paths.d.ts.map +1 -0
- package/dist/app-publish-paths.js +32 -0
- package/dist/app-publish-workspace.d.ts +16 -0
- package/dist/app-publish-workspace.d.ts.map +1 -0
- package/dist/app-publish-workspace.js +175 -0
- package/dist/artifacts-git-runtime.d.ts +5 -0
- package/dist/artifacts-git-runtime.d.ts.map +1 -1
- package/dist/artifacts-git-runtime.js +70 -25
- package/dist/artifacts-session-merge.d.ts +62 -0
- package/dist/artifacts-session-merge.d.ts.map +1 -0
- package/dist/artifacts-session-merge.js +123 -0
- package/dist/dataset-append-workflow.d.ts +12 -0
- package/dist/dataset-append-workflow.d.ts.map +1 -0
- package/dist/dataset-append-workflow.js +198 -0
- package/dist/dataset-commands.d.ts.map +1 -1
- package/dist/dataset-commands.js +11 -130
- package/dist/dataset-transfer-download.js +6 -6
- package/dist/dataset-transfer-upload.js +8 -8
- package/dist/git-credential.d.ts +1 -1
- package/dist/git-credential.d.ts.map +1 -1
- package/dist/git-credential.js +8 -1
- package/dist/help.js +3 -3
- package/dist/hosted-boot.d.ts +11 -0
- package/dist/hosted-boot.d.ts.map +1 -1
- package/dist/hosted-boot.js +46 -1
- package/dist/hosted-session-cli.d.ts.map +1 -1
- package/dist/hosted-session-cli.js +6 -1
- package/dist/hosted-session-seed.d.ts +45 -0
- package/dist/hosted-session-seed.d.ts.map +1 -0
- package/dist/hosted-session-seed.js +61 -0
- package/dist/jsonl-stream.d.ts +1 -0
- package/dist/jsonl-stream.d.ts.map +1 -1
- package/dist/jsonl-stream.js +7 -0
- package/dist/provider-list-workflow.d.ts +3 -0
- package/dist/provider-list-workflow.d.ts.map +1 -0
- package/dist/provider-list-workflow.js +95 -0
- package/dist/provider-wire-record.d.ts +3 -0
- package/dist/provider-wire-record.d.ts.map +1 -0
- package/dist/provider-wire-record.js +4 -0
- package/dist/providers-cli.d.ts.map +1 -1
- package/dist/providers-cli.js +5 -15
- package/dist/publish-merge-job.d.ts +68 -0
- package/dist/publish-merge-job.d.ts.map +1 -0
- package/dist/publish-merge-job.js +320 -0
- package/dist/publish-merge-timing.d.ts +18 -0
- package/dist/publish-merge-timing.d.ts.map +1 -0
- package/dist/publish-merge-timing.js +23 -0
- package/dist/workbench-cli.d.ts +4 -0
- package/dist/workbench-cli.d.ts.map +1 -1
- package/dist/workbench-cli.js +11 -1
- package/dist/workspace-bootstrap-job.d.ts +1 -0
- package/dist/workspace-bootstrap-job.d.ts.map +1 -1
- package/dist/workspace-bootstrap-job.js +1 -1
- package/package.json +5 -2
- package/src/app-commands.ts +234 -66
- package/src/app-publish-failure.ts +7 -0
- package/src/app-publish-paths.ts +27 -0
- package/src/app-publish-workspace.ts +207 -0
- package/src/artifacts-git-runtime.ts +73 -31
- package/src/artifacts-session-merge.ts +253 -0
- package/src/dataset-append-workflow.ts +205 -0
- package/src/dataset-commands.ts +10 -170
- package/src/dataset-transfer-download.ts +5 -5
- package/src/dataset-transfer-upload.ts +7 -7
- package/src/git-credential.ts +8 -1
- package/src/help.ts +3 -3
- package/src/hosted-boot.ts +56 -1
- package/src/hosted-session-cli.ts +6 -1
- package/src/hosted-session-seed.ts +78 -0
- package/src/jsonl-stream.ts +8 -0
- package/src/provider-list-workflow.ts +108 -0
- package/src/provider-wire-record.ts +4 -0
- package/src/providers-cli.ts +5 -13
- package/src/publish-merge-job.ts +350 -0
- package/src/publish-merge-timing.ts +27 -0
- package/src/workbench-cli.ts +15 -1
- package/src/workspace-bootstrap-job.ts +1 -1
- package/vendor/skills/orizu/flows/onboard.md +1 -1
- package/vendor/skills/orizu/references/authority-map.md +2 -2
- package/vendor/skills/orizu/references/building-apps.md +24 -0
- package/vendor/skills/orizu/references/cli-reference.md +13 -4
- package/vendor/skills/orizu/references/optimization-reports.md +1 -1
- package/vendor/skills/orizu/references/primer.md +1 -0
- package/vendor/skills/orizu/references/prompt-control-plane.md +9 -5
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
|
-
import {
|
|
2
|
+
import { rmSync } from 'node:fs'
|
|
3
|
+
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
3
5
|
import { join } from 'node:path'
|
|
4
|
-
import type { Writable } from 'node:stream'
|
|
5
6
|
|
|
6
7
|
const ARTIFACT_TOKEN_BODY = /^[^\s\u0000-\u001f\u007f]{16,1024}$/
|
|
7
8
|
const ARTIFACT_TOKEN_ADVISORY_SUFFIX = '?expires='
|
|
8
9
|
const ARTIFACT_TOKEN_BARE_BODY = /art_v1_[0-9a-fA-F]{40}/
|
|
9
10
|
const DEFAULT_GIT_TIMEOUT_MS = 30_000
|
|
10
11
|
const GIT_TERMINATION_GRACE_MS = 1_000
|
|
12
|
+
/** Names the one-use key file, set on the git process that needs it and nowhere else. */
|
|
13
|
+
const GIT_KEY_FILE_ENV = 'ORIZU_GIT_KEY_FILE'
|
|
11
14
|
const GIT_CONTEXT_ENV_KEYS = [
|
|
12
15
|
'GIT_DIR',
|
|
13
16
|
'GIT_WORK_TREE',
|
|
@@ -118,7 +121,8 @@ export async function createEphemeralAskPass(
|
|
|
118
121
|
case "$1" in
|
|
119
122
|
*sername*) printf '%s\\n' x ;;
|
|
120
123
|
*assword*)
|
|
121
|
-
|
|
124
|
+
[ -n "$${GIT_KEY_FILE_ENV}" ] || exit 1
|
|
125
|
+
IFS= read -r password < "$${GIT_KEY_FILE_ENV}" || exit 1
|
|
122
126
|
[ -n "$password" ] || exit 1
|
|
123
127
|
printf '%s\\n' "$password"
|
|
124
128
|
;;
|
|
@@ -204,10 +208,70 @@ function signalGitProcess(child: ReturnType<typeof spawn>, signal: NodeJS.Signal
|
|
|
204
208
|
}
|
|
205
209
|
}
|
|
206
210
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
+
/** Key folders this process holds right now. An abrupt end of the process
|
|
212
|
+
* skips the runner's own cleanup, so they are also removed, best effort, on
|
|
213
|
+
* exit, and on a termination signal that ends the process. A script that
|
|
214
|
+
* handles the signal itself keeps its keys: its running command may still need
|
|
215
|
+
* them, and the exit listener covers it later. SIGKILL cannot be caught: a
|
|
216
|
+
* folder it leaves behind holds a key that still expires on its own. */
|
|
217
|
+
const liveKeyDirs = new Set<string>()
|
|
218
|
+
let isExitCleanupInstalled = false
|
|
219
|
+
// SIGHUP and SIGQUIT run no exit handlers either; operator scripts run over SSH.
|
|
220
|
+
const TERMINATION_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'] as const
|
|
221
|
+
|
|
222
|
+
function removeLiveKeyDirs(): void {
|
|
223
|
+
for (const dir of liveKeyDirs) {
|
|
224
|
+
try {
|
|
225
|
+
rmSync(dir, { recursive: true, force: true })
|
|
226
|
+
} catch {
|
|
227
|
+
// Best effort: the process is ending.
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
liveKeyDirs.clear()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function handleTerminationSignal(signal: NodeJS.Signals): void {
|
|
234
|
+
// Another listener decides whether the process ends; leave the keys to it.
|
|
235
|
+
if (process.listenerCount(signal) !== 1) return
|
|
236
|
+
removeLiveKeyDirs()
|
|
237
|
+
// End the way the signal would have without this listener.
|
|
238
|
+
process.off(signal, handleTerminationSignal)
|
|
239
|
+
process.kill(process.pid, signal)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function installExitCleanup(): void {
|
|
243
|
+
if (isExitCleanupInstalled) return
|
|
244
|
+
isExitCleanupInstalled = true
|
|
245
|
+
process.on('exit', removeLiveKeyDirs)
|
|
246
|
+
for (const signal of TERMINATION_SIGNALS) process.on(signal, handleTerminationSignal)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Runs one git command. With auth, the key goes in a 0600 file inside a
|
|
250
|
+
* fresh 0700 folder, named to that git process only by ORIZU_GIT_KEY_FILE.
|
|
251
|
+
* Askpass reads it from there. The folder is removed when the command
|
|
252
|
+
* settles, however it ends: success, failure, timeout or cancel; and if this
|
|
253
|
+
* process ends first, on its way out (see liveKeyDirs). */
|
|
254
|
+
export const defaultGitRunner: GitRunner = async (args, options) => {
|
|
255
|
+
if (!options.auth) return runGitProcess(args, options, null)
|
|
256
|
+
installExitCleanup()
|
|
257
|
+
const keyDir = await mkdtemp(join(tmpdir(), 'orizu-git-key-'))
|
|
258
|
+
liveKeyDirs.add(keyDir)
|
|
259
|
+
try {
|
|
260
|
+
await chmod(keyDir, 0o700)
|
|
261
|
+
const keyFile = join(keyDir, 'key')
|
|
262
|
+
await writeFile(keyFile, `${options.auth.token}\n`, { mode: 0o600, flag: 'wx' })
|
|
263
|
+
return await runGitProcess(args, options, keyFile)
|
|
264
|
+
} finally {
|
|
265
|
+
await rm(keyDir, { recursive: true, force: true })
|
|
266
|
+
liveKeyDirs.delete(keyDir)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const runGitProcess = (
|
|
271
|
+
args: string[],
|
|
272
|
+
{ cwd, auth, env: suppliedEnv, commitIdentityEnv, timeoutMs = DEFAULT_GIT_TIMEOUT_MS, signal }: GitRunOptions,
|
|
273
|
+
keyFile: string | null
|
|
274
|
+
): Promise<GitCommandResult> =>
|
|
211
275
|
new Promise((resolve, reject) => {
|
|
212
276
|
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
|
|
213
277
|
reject(new Error('Git timeout must be a positive integer'))
|
|
@@ -225,7 +289,7 @@ export const defaultGitRunner: GitRunner = (
|
|
|
225
289
|
return
|
|
226
290
|
}
|
|
227
291
|
const baseEnv = auth
|
|
228
|
-
? buildGitAuthEnvironment(auth, suppliedEnv)
|
|
292
|
+
? { ...buildGitAuthEnvironment(auth, suppliedEnv), ...(keyFile ? { [GIT_KEY_FILE_ENV]: keyFile } : {}) }
|
|
229
293
|
: buildUnauthenticatedGitEnvironment(suppliedEnv)
|
|
230
294
|
let env = baseEnv
|
|
231
295
|
if (commitIdentityEnv) {
|
|
@@ -237,7 +301,7 @@ export const defaultGitRunner: GitRunner = (
|
|
|
237
301
|
cwd,
|
|
238
302
|
env,
|
|
239
303
|
detached: process.platform !== 'win32',
|
|
240
|
-
stdio:
|
|
304
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
241
305
|
})
|
|
242
306
|
let stdout = ''
|
|
243
307
|
let stderr = ''
|
|
@@ -314,28 +378,6 @@ export const defaultGitRunner: GitRunner = (
|
|
|
314
378
|
child.on('close', (code) => {
|
|
315
379
|
finish(termination === 'timeout' ? 124 : termination === 'cancelled' ? 130 : (code ?? 1))
|
|
316
380
|
})
|
|
317
|
-
|
|
318
|
-
if (auth) {
|
|
319
|
-
const credentialPipe = child.stdio[3] as Writable | null
|
|
320
|
-
if (!credentialPipe) {
|
|
321
|
-
fail(new Error('Could not establish one-use Git credential pipe'))
|
|
322
|
-
return
|
|
323
|
-
}
|
|
324
|
-
credentialPipe.on('error', () => {
|
|
325
|
-
/* EPIPE if Git fails before auth. */
|
|
326
|
-
})
|
|
327
|
-
if (termination) {
|
|
328
|
-
credentialPipe.destroy()
|
|
329
|
-
return
|
|
330
|
-
}
|
|
331
|
-
try {
|
|
332
|
-
credentialPipe.end(`${auth.token}\n`)
|
|
333
|
-
} catch (error) {
|
|
334
|
-
fail(
|
|
335
|
-
error instanceof Error ? error : new Error('Could not write the one-use Git credential')
|
|
336
|
-
)
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
381
|
})
|
|
340
382
|
|
|
341
383
|
export async function gitOk(
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
defaultGitRunner,
|
|
6
|
+
gitOk,
|
|
7
|
+
redactSecrets,
|
|
8
|
+
revParse,
|
|
9
|
+
type GitAuth,
|
|
10
|
+
type GitRunner,
|
|
11
|
+
} from './artifacts-git-runtime.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The publisher's git step (ORI-2279): merge a session copy into the team
|
|
15
|
+
* copy, ask for permission, then push with compare-and-swap and read back.
|
|
16
|
+
*
|
|
17
|
+
* Runs in a server-owned process with `git` on PATH (the coordinator's merge
|
|
18
|
+
* job), never in an agent's sandbox. It reads no environment and no
|
|
19
|
+
* database: the caller's callbacks carry the permission answer and the
|
|
20
|
+
* one-time team-copy write key.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const SESSION_MERGE_MAX_ATTEMPTS = 3
|
|
24
|
+
|
|
25
|
+
const CANONICAL_TRACKING_REF = 'refs/remotes/orizu-canonical/head'
|
|
26
|
+
const SOURCE_TRACKING_REF = 'refs/remotes/orizu-source/head'
|
|
27
|
+
|
|
28
|
+
interface SessionMergeEndpoint {
|
|
29
|
+
readonly remote: string
|
|
30
|
+
/** Read access. Omitted only for local remotes in tests. */
|
|
31
|
+
readonly auth?: GitAuth
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface PreparedSessionMerge {
|
|
35
|
+
readonly previousCanonicalSha: string
|
|
36
|
+
readonly sourceSha: string
|
|
37
|
+
readonly mergeSha: string
|
|
38
|
+
readonly attempt: number
|
|
39
|
+
/** The merge folder: both copies' fetched objects plus the merge commit. */
|
|
40
|
+
readonly worktree: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type SessionMergePushResult = 'pushed' | 'stale'
|
|
44
|
+
|
|
45
|
+
export interface PublishSessionMergeInput {
|
|
46
|
+
/** A writable folder; each attempt merges in its own temporary subfolder. */
|
|
47
|
+
readonly root: string
|
|
48
|
+
readonly canonical: SessionMergeEndpoint
|
|
49
|
+
readonly source: SessionMergeEndpoint
|
|
50
|
+
readonly canonicalRef: string
|
|
51
|
+
readonly sourceRef: string
|
|
52
|
+
/** The session head the publish was approved for. */
|
|
53
|
+
readonly expectedSourceSha: string
|
|
54
|
+
readonly commitMessage: string
|
|
55
|
+
readonly commitIdentityEnv: Readonly<Record<string, string>>
|
|
56
|
+
/** The final permission check, asked after every merge and before any write. */
|
|
57
|
+
readonly authorize: (merge: PreparedSessionMerge) => Promise<boolean>
|
|
58
|
+
/**
|
|
59
|
+
* Mints the one-time team-copy write key, runs `push` once with it, then
|
|
60
|
+
* revokes it. `undefined` is allowed only for local remotes in tests.
|
|
61
|
+
*/
|
|
62
|
+
readonly withCanonicalWrite: (
|
|
63
|
+
push: (auth: GitAuth | undefined) => Promise<SessionMergePushResult>
|
|
64
|
+
) => Promise<SessionMergePushResult>
|
|
65
|
+
readonly timeoutMs?: number
|
|
66
|
+
readonly signal?: AbortSignal
|
|
67
|
+
readonly runGit?: GitRunner
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type PublishSessionMergeResult =
|
|
71
|
+
| {
|
|
72
|
+
readonly kind: 'merged'
|
|
73
|
+
readonly previousCanonicalSha: string
|
|
74
|
+
readonly sourceSha: string
|
|
75
|
+
readonly mergeSha: string
|
|
76
|
+
readonly attempts: number
|
|
77
|
+
}
|
|
78
|
+
| {
|
|
79
|
+
/** The session head is already in the team copy: nothing to push. */
|
|
80
|
+
readonly kind: 'already_merged'
|
|
81
|
+
readonly canonicalSha: string
|
|
82
|
+
readonly sourceSha: string
|
|
83
|
+
}
|
|
84
|
+
| {
|
|
85
|
+
readonly kind: 'conflict'
|
|
86
|
+
readonly files: readonly string[]
|
|
87
|
+
readonly canonicalSha: string
|
|
88
|
+
readonly sourceSha: string
|
|
89
|
+
}
|
|
90
|
+
| { readonly kind: 'denied' }
|
|
91
|
+
| { readonly kind: 'race_lost'; readonly attempts: number }
|
|
92
|
+
|
|
93
|
+
type Preparation =
|
|
94
|
+
| { readonly kind: 'merge'; readonly merge: PreparedSessionMerge }
|
|
95
|
+
| Extract<PublishSessionMergeResult, { kind: 'already_merged' | 'conflict' }>
|
|
96
|
+
|
|
97
|
+
export async function publishSessionMerge(
|
|
98
|
+
input: PublishSessionMergeInput
|
|
99
|
+
): Promise<PublishSessionMergeResult> {
|
|
100
|
+
const runGit = input.runGit ?? defaultGitRunner
|
|
101
|
+
for (let attempt = 1; attempt <= SESSION_MERGE_MAX_ATTEMPTS; attempt += 1) {
|
|
102
|
+
const worktree = await mkdtemp(join(input.root, 'session-merge-'))
|
|
103
|
+
try {
|
|
104
|
+
const prepared = await prepareMerge(input, runGit, worktree, attempt)
|
|
105
|
+
if (prepared.kind !== 'merge') return prepared
|
|
106
|
+
if (!(await input.authorize(prepared.merge))) return { kind: 'denied' }
|
|
107
|
+
const pushed = await input.withCanonicalWrite((auth) =>
|
|
108
|
+
pushMerge(input, runGit, worktree, prepared.merge, auth)
|
|
109
|
+
)
|
|
110
|
+
if (pushed === 'stale') continue
|
|
111
|
+
await readBack(input, runGit, worktree, prepared.merge.mergeSha)
|
|
112
|
+
return {
|
|
113
|
+
kind: 'merged',
|
|
114
|
+
previousCanonicalSha: prepared.merge.previousCanonicalSha,
|
|
115
|
+
sourceSha: prepared.merge.sourceSha,
|
|
116
|
+
mergeSha: prepared.merge.mergeSha,
|
|
117
|
+
attempts: attempt,
|
|
118
|
+
}
|
|
119
|
+
} finally {
|
|
120
|
+
await rm(worktree, { recursive: true, force: true })
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { kind: 'race_lost', attempts: SESSION_MERGE_MAX_ATTEMPTS }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function prepareMerge(
|
|
127
|
+
input: PublishSessionMergeInput,
|
|
128
|
+
runGit: GitRunner,
|
|
129
|
+
worktree: string,
|
|
130
|
+
attempt: number
|
|
131
|
+
): Promise<Preparation> {
|
|
132
|
+
await gitOk(runGit, ['init', '-q'], { cwd: worktree }, 'initialise merge folder')
|
|
133
|
+
await fetchInto(input, runGit, worktree, input.canonical, input.canonicalRef, CANONICAL_TRACKING_REF)
|
|
134
|
+
await fetchInto(input, runGit, worktree, input.source, input.sourceRef, SOURCE_TRACKING_REF)
|
|
135
|
+
const canonicalSha = await revParse(runGit, worktree, CANONICAL_TRACKING_REF)
|
|
136
|
+
const sourceSha = await revParse(runGit, worktree, SOURCE_TRACKING_REF)
|
|
137
|
+
if (sourceSha !== input.expectedSourceSha) {
|
|
138
|
+
throw new Error('Session copy moved since the publish was approved')
|
|
139
|
+
}
|
|
140
|
+
if (await isAncestor(runGit, worktree, sourceSha, canonicalSha)) {
|
|
141
|
+
return { kind: 'already_merged', canonicalSha, sourceSha }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await gitOk(
|
|
145
|
+
runGit,
|
|
146
|
+
['checkout', '-q', '--detach', canonicalSha],
|
|
147
|
+
{ cwd: worktree },
|
|
148
|
+
'check out the team copy head'
|
|
149
|
+
)
|
|
150
|
+
const merge = await runGit(['merge', '--no-ff', '--no-edit', '-m', input.commitMessage, sourceSha], {
|
|
151
|
+
cwd: worktree,
|
|
152
|
+
commitIdentityEnv: input.commitIdentityEnv,
|
|
153
|
+
})
|
|
154
|
+
if (merge.exitCode === 1) {
|
|
155
|
+
const unmerged = await gitOk(
|
|
156
|
+
runGit,
|
|
157
|
+
['diff', '--name-only', '--diff-filter=U', '-z'],
|
|
158
|
+
{ cwd: worktree },
|
|
159
|
+
'list conflicted files'
|
|
160
|
+
)
|
|
161
|
+
const files = unmerged.split('\u0000').filter(Boolean)
|
|
162
|
+
if (files.length > 0) return { kind: 'conflict', files, canonicalSha, sourceSha }
|
|
163
|
+
}
|
|
164
|
+
if (merge.exitCode !== 0) {
|
|
165
|
+
throw new Error(`merge session into team copy failed: ${(merge.stderr || merge.stdout).trim()}`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const mergeSha = await revParse(runGit, worktree, 'HEAD')
|
|
169
|
+
const parents = (
|
|
170
|
+
await gitOk(runGit, ['rev-list', '--parents', '-n', '1', mergeSha], { cwd: worktree }, 'inspect merge parents')
|
|
171
|
+
)
|
|
172
|
+
.split(/\s+/)
|
|
173
|
+
.slice(1)
|
|
174
|
+
if (parents.length !== 2 || parents[0] !== canonicalSha || parents[1] !== sourceSha) {
|
|
175
|
+
throw new Error('Merge did not keep the team head and session head as its parents')
|
|
176
|
+
}
|
|
177
|
+
return { kind: 'merge', merge: { previousCanonicalSha: canonicalSha, sourceSha, mergeSha, attempt, worktree } }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function pushMerge(
|
|
181
|
+
input: PublishSessionMergeInput,
|
|
182
|
+
runGit: GitRunner,
|
|
183
|
+
worktree: string,
|
|
184
|
+
merge: PreparedSessionMerge,
|
|
185
|
+
auth: GitAuth | undefined
|
|
186
|
+
): Promise<SessionMergePushResult> {
|
|
187
|
+
// The lease is the team head this merge was built on, not whatever the
|
|
188
|
+
// remote holds now: if another publish landed after our fetch, the push is
|
|
189
|
+
// refused ("stale info") and the caller merges again on the new head.
|
|
190
|
+
const result = await runGit(
|
|
191
|
+
[
|
|
192
|
+
'push',
|
|
193
|
+
'--porcelain',
|
|
194
|
+
`--force-with-lease=${input.canonicalRef}:${merge.previousCanonicalSha}`,
|
|
195
|
+
input.canonical.remote,
|
|
196
|
+
`${merge.mergeSha}:${input.canonicalRef}`,
|
|
197
|
+
],
|
|
198
|
+
{ cwd: worktree, auth, timeoutMs: input.timeoutMs, signal: input.signal }
|
|
199
|
+
)
|
|
200
|
+
if (result.exitCode === 0) return 'pushed'
|
|
201
|
+
const output = `${result.stdout}\n${result.stderr}`
|
|
202
|
+
if (!result.timedOut && !result.cancelled && /\(stale info\)/.test(output)) return 'stale'
|
|
203
|
+
const detail = redactSecrets(output.trim(), auth ? [auth.token] : [])
|
|
204
|
+
throw new Error(`push merge to team copy failed: ${detail}`)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Proves the push landed: the team head is the merge, or a later commit containing it. */
|
|
208
|
+
async function readBack(
|
|
209
|
+
input: PublishSessionMergeInput,
|
|
210
|
+
runGit: GitRunner,
|
|
211
|
+
worktree: string,
|
|
212
|
+
mergeSha: string
|
|
213
|
+
): Promise<void> {
|
|
214
|
+
const listed = await gitOk(
|
|
215
|
+
runGit,
|
|
216
|
+
['ls-remote', '--refs', input.canonical.remote, input.canonicalRef],
|
|
217
|
+
{ cwd: worktree, auth: input.canonical.auth, timeoutMs: input.timeoutMs, signal: input.signal },
|
|
218
|
+
'read back the team copy head'
|
|
219
|
+
)
|
|
220
|
+
const [head, ref] = listed.split(/\s+/)
|
|
221
|
+
if (ref !== input.canonicalRef || !/^[0-9a-f]{40}$/.test(head ?? '')) {
|
|
222
|
+
throw new Error('Team copy read-back returned an unexpected answer')
|
|
223
|
+
}
|
|
224
|
+
if (head === mergeSha) return
|
|
225
|
+
await fetchInto(input, runGit, worktree, input.canonical, input.canonicalRef, CANONICAL_TRACKING_REF)
|
|
226
|
+
const fetched = await revParse(runGit, worktree, CANONICAL_TRACKING_REF)
|
|
227
|
+
if (!(await isAncestor(runGit, worktree, mergeSha, fetched))) {
|
|
228
|
+
throw new Error('Team copy read-back does not contain the pushed merge')
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function fetchInto(
|
|
233
|
+
input: PublishSessionMergeInput,
|
|
234
|
+
runGit: GitRunner,
|
|
235
|
+
worktree: string,
|
|
236
|
+
endpoint: SessionMergeEndpoint,
|
|
237
|
+
ref: string,
|
|
238
|
+
trackingRef: string
|
|
239
|
+
): Promise<void> {
|
|
240
|
+
await gitOk(
|
|
241
|
+
runGit,
|
|
242
|
+
['fetch', '-q', '--no-tags', endpoint.remote, `+${ref}:${trackingRef}`],
|
|
243
|
+
{ cwd: worktree, auth: endpoint.auth, timeoutMs: input.timeoutMs, signal: input.signal },
|
|
244
|
+
`fetch ${ref}`
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function isAncestor(runGit: GitRunner, cwd: string, ancestor: string, descendant: string): Promise<boolean> {
|
|
249
|
+
const result = await runGit(['merge-base', '--is-ancestor', ancestor, descendant], { cwd })
|
|
250
|
+
if (result.exitCode === 0) return true
|
|
251
|
+
if (result.exitCode === 1) return false
|
|
252
|
+
throw new Error(`check ancestry failed: ${result.stderr.trim()}`)
|
|
253
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { statSync } from 'node:fs'
|
|
2
|
+
import { extname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { Context, Effect, Result, Schema } from 'effect'
|
|
5
|
+
|
|
6
|
+
import { parseDatasetFile } from './file-parser.js'
|
|
7
|
+
import { datasetTransferTimeoutMs } from './dataset-transfer-policy.js'
|
|
8
|
+
import { authedFetch } from './http.js'
|
|
9
|
+
import { streamJsonlRowChunks } from './jsonl-stream.js'
|
|
10
|
+
import { sanitizeTerminalText } from './json-response.js'
|
|
11
|
+
|
|
12
|
+
type Rows = ReadonlyArray<Readonly<Record<string, unknown>>>
|
|
13
|
+
export interface DatasetAppendInput {
|
|
14
|
+
datasetId: string
|
|
15
|
+
file: string
|
|
16
|
+
}
|
|
17
|
+
interface AppendIo {
|
|
18
|
+
json: boolean
|
|
19
|
+
print: (message: string) => void
|
|
20
|
+
}
|
|
21
|
+
type Outcome = 'rejected' | 'unknown' | 'not_sent'
|
|
22
|
+
interface FailedChunk { index: number; inputRows: number; outcome: Outcome }
|
|
23
|
+
interface Progress {
|
|
24
|
+
datasetId: string | null
|
|
25
|
+
confirmedRows: number
|
|
26
|
+
confirmedChunks: number
|
|
27
|
+
chunk: FailedChunk | null
|
|
28
|
+
}
|
|
29
|
+
class AppendFailure extends Error {
|
|
30
|
+
readonly _tag = 'AppendFailure'
|
|
31
|
+
constructor(message: string, readonly outcome: Outcome) { super(sanitizeTerminalText(message)) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const Acknowledgement = Schema.Struct({
|
|
35
|
+
dataset: Schema.Struct({ id: Schema.String, name: Schema.String, rowCount: Schema.Natural }),
|
|
36
|
+
appendedCount: Schema.Natural,
|
|
37
|
+
})
|
|
38
|
+
type Acknowledgement = typeof Acknowledgement.Type
|
|
39
|
+
const RowsSchema = Schema.Array(Schema.Record(Schema.String, Schema.Unknown))
|
|
40
|
+
const appendTransport = Context.Service<{
|
|
41
|
+
append: (datasetId: string, rows: Rows) => Effect.Effect<Acknowledgement, AppendFailure>
|
|
42
|
+
}>('orizu/cli/DatasetAppendTransport')
|
|
43
|
+
|
|
44
|
+
// Only responses naming an existing rows-route rejection establish a known
|
|
45
|
+
// refusal. A proxy body or a lost body is not evidence that nothing committed.
|
|
46
|
+
function isKnownRefusal(message: string, status: number): boolean {
|
|
47
|
+
// These exact templates come from artifact preparation before the append RPC.
|
|
48
|
+
// Status alone or an arbitrary artifact/proxy prefix cannot prove rejection.
|
|
49
|
+
if (status === 400 && (
|
|
50
|
+
message === 'Dataset row id cannot be stored as an artifact' ||
|
|
51
|
+
/^Invalid dataset row artifact reference at .+$/.test(message) ||
|
|
52
|
+
/^Dataset row artifact ref at .+ (?:is nested; v1 supports top-level fields only|was not issued by this server|belongs to a different project|has been deleted|has not been finalized|is expired|was issued to a different user|is not attachable|does not match server metadata|targets a different (?:dataset|field|row id))$/.test(message)
|
|
53
|
+
)) return true
|
|
54
|
+
if (status === 413 && (
|
|
55
|
+
/^Dataset row .+ exceeds 65536 bytes after externalizing top-level fields$/.test(message) ||
|
|
56
|
+
/^.+ exceeds (?:26214400 bytes before compression|10485760 bytes after compression)$/.test(message)
|
|
57
|
+
)) return true
|
|
58
|
+
return /^(Missing bearer token|Unauthorized|Dataset not found|Only project curators\/admins can modify dataset rows|Dataset is locked and cannot be mutated|Duplicate row ids provided|One or more row ids already exist in this dataset|rows must contain at least one item|rows\[\d+\] must be an object|Dataset row is too large|Each row in p_rows must)/.test(message)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function liveTransport(timeoutMs: number, commandSignal: AbortSignal, progress: Progress) {
|
|
62
|
+
return {
|
|
63
|
+
append: (datasetId: string, rows: Rows) => Effect.suspend(() => {
|
|
64
|
+
let admitted = false
|
|
65
|
+
let timedOut = false
|
|
66
|
+
const attempt = new AbortController()
|
|
67
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
68
|
+
return Effect.tryPromise({
|
|
69
|
+
try: async signal => {
|
|
70
|
+
const abortFromEffect = () => attempt.abort(signal.reason)
|
|
71
|
+
const abortFromCommand = () => attempt.abort(commandSignal.reason)
|
|
72
|
+
try {
|
|
73
|
+
// Node 20.0 has no AbortSignal.any. This attempt owns both links
|
|
74
|
+
// and the physical fetch/body signal until its receipt is consumed.
|
|
75
|
+
if (signal.aborted) abortFromEffect()
|
|
76
|
+
else signal.addEventListener('abort', abortFromEffect, { once: true })
|
|
77
|
+
if (commandSignal.aborted) abortFromCommand()
|
|
78
|
+
else commandSignal.addEventListener('abort', abortFromCommand, { once: true })
|
|
79
|
+
const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/rows`, {
|
|
80
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
81
|
+
body: JSON.stringify({ rows }), signal: attempt.signal,
|
|
82
|
+
}, () => {
|
|
83
|
+
admitted = true
|
|
84
|
+
if (progress.chunk) progress.chunk.outcome = 'unknown'
|
|
85
|
+
if (timer) clearTimeout(timer)
|
|
86
|
+
timer = setTimeout(() => { timedOut = true; attempt.abort() }, timeoutMs)
|
|
87
|
+
})
|
|
88
|
+
// The single admitted-attempt deadline includes the complete body.
|
|
89
|
+
const text = await response.text()
|
|
90
|
+
let value: unknown
|
|
91
|
+
try { value = JSON.parse(text) } catch { throw new AppendFailure('Append returned an invalid acknowledgement', 'unknown') }
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
const message = value && typeof value === 'object' && 'error' in value && typeof value.error === 'string' ? value.error : ''
|
|
94
|
+
const known = response.status >= 400 && response.status < 500 && isKnownRefusal(message, response.status)
|
|
95
|
+
throw new AppendFailure(message ? `Append failed: ${message}` : 'Append failed', known ? 'rejected' : 'unknown')
|
|
96
|
+
}
|
|
97
|
+
const result = Schema.decodeUnknownResult(Acknowledgement)(value)
|
|
98
|
+
if (Result.isFailure(result) || result.success.dataset.id.toLowerCase() !== datasetId.toLowerCase() || result.success.appendedCount !== rows.length) throw new AppendFailure('Append returned an invalid acknowledgement', 'unknown')
|
|
99
|
+
return result.success
|
|
100
|
+
} finally {
|
|
101
|
+
if (timer) clearTimeout(timer)
|
|
102
|
+
signal.removeEventListener('abort', abortFromEffect)
|
|
103
|
+
commandSignal.removeEventListener('abort', abortFromCommand)
|
|
104
|
+
attempt.abort()
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
catch: cause => cause instanceof AppendFailure ? cause : new AppendFailure(
|
|
108
|
+
timedOut ? 'DATASET_TRANSFER_TIMEOUT: Dataset append exceeded its deadline' : admitted ? 'fetch failed' : cause instanceof Error ? cause.message : 'Append failed before the request was sent',
|
|
109
|
+
admitted ? 'unknown' : 'not_sent',
|
|
110
|
+
),
|
|
111
|
+
})
|
|
112
|
+
}),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function recovery(progress: Progress): string {
|
|
117
|
+
if (progress.chunk?.outcome === 'unknown') return 'This chunk may already be committed. Inspect the dataset and reconcile its rows before rerunning; do not blindly repeat this write.'
|
|
118
|
+
if (progress.confirmedRows > 0) return 'Earlier acknowledged rows remain appended. Inspect the confirmed source prefix before retrying the remaining input.'
|
|
119
|
+
return 'Resolve the reported error before rerunning the command.'
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function appendProgram(input: DatasetAppendInput, timeoutMs: number, progress: Progress, io: AppendIo, signal: AbortSignal) {
|
|
123
|
+
return Effect.gen(function*() {
|
|
124
|
+
const rows = yield* Effect.try({
|
|
125
|
+
try: () => {
|
|
126
|
+
if (extname(input.file).toLowerCase() === '.jsonl') return undefined
|
|
127
|
+
let bytes: number
|
|
128
|
+
try { bytes = statSync(input.file).size } catch (cause) {
|
|
129
|
+
const error = cause as NodeJS.ErrnoException
|
|
130
|
+
if (error.code === 'ENOENT') throw new Error(`File not found: ${input.file}. Check the path and filename, then retry.`)
|
|
131
|
+
if (error.code === 'EPERM' || error.code === 'EACCES') throw new Error(`Cannot read file: ${input.file}. Grant folder permission to your terminal app and retry.`)
|
|
132
|
+
throw new Error(`Failed to access file ${input.file}: ${error.message}`)
|
|
133
|
+
}
|
|
134
|
+
if (bytes > 50 * 1024 * 1024) throw new Error(`Input file is ${(bytes / (1024 * 1024)).toFixed(1)} MB, which exceeds the 50 MB limit. Split the file into smaller parts and append each separately.`)
|
|
135
|
+
const parsed = parseDatasetFile(input.file).rows
|
|
136
|
+
if (!Array.isArray(parsed) || parsed.length === 0) throw new Error('Dataset append file must contain at least one row')
|
|
137
|
+
return parsed
|
|
138
|
+
},
|
|
139
|
+
catch: cause => new AppendFailure(cause instanceof Error ? cause.message : 'Could not read append input', 'not_sent'),
|
|
140
|
+
})
|
|
141
|
+
const source = yield* Effect.acquireRelease(
|
|
142
|
+
Effect.sync(() => rows ? (async function*() {
|
|
143
|
+
for (let offset = 0; offset < rows.length; offset += 500) yield rows.slice(offset, offset + 500)
|
|
144
|
+
})() : streamJsonlRowChunks(input.file, { signal })),
|
|
145
|
+
iterator => Effect.promise(async () => { await iterator.return(undefined) }),
|
|
146
|
+
)
|
|
147
|
+
const transport = liveTransport(timeoutMs, signal, progress)
|
|
148
|
+
let acknowledgement: Acknowledgement | undefined
|
|
149
|
+
let index = 0
|
|
150
|
+
while (true) {
|
|
151
|
+
const next = yield* Effect.tryPromise({ try: () => source.next(), catch: cause => new AppendFailure(cause instanceof Error ? cause.message : 'Could not read append input', 'not_sent') })
|
|
152
|
+
if (next.done) break
|
|
153
|
+
index++
|
|
154
|
+
progress.chunk = { index, inputRows: next.value.length, outcome: 'not_sent' }
|
|
155
|
+
const rows = yield* Schema.decodeUnknownEffect(RowsSchema)(next.value).pipe(Effect.mapError(() => new AppendFailure('Dataset rows must be JSON objects', 'not_sent')))
|
|
156
|
+
if (!io.json) yield* Effect.sync(() => io.print(`Uploading chunk ${index} (${rows.length} rows)...`))
|
|
157
|
+
acknowledgement = yield* appendTransport.use(api => api.append(input.datasetId, rows)).pipe(Effect.provideService(appendTransport, transport))
|
|
158
|
+
progress.confirmedRows += acknowledgement.appendedCount
|
|
159
|
+
progress.confirmedChunks++
|
|
160
|
+
progress.chunk = null
|
|
161
|
+
}
|
|
162
|
+
if (!acknowledgement) return yield* Effect.fail(new AppendFailure('Dataset append file must contain at least one row', 'not_sent'))
|
|
163
|
+
return { dataset: acknowledgement.dataset, appendedCount: progress.confirmedRows }
|
|
164
|
+
}).pipe(Effect.scoped)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Owns standalone append's source, admitted writes, progress and recovery. */
|
|
168
|
+
export async function appendDatasetWorkflow(resolveInput: () => Promise<DatasetAppendInput>, io: AppendIo): Promise<number> {
|
|
169
|
+
const controller = new AbortController()
|
|
170
|
+
const progress: Progress = { datasetId: null, confirmedRows: 0, confirmedChunks: 0, chunk: null }
|
|
171
|
+
let interrupted: 130 | 143 | undefined
|
|
172
|
+
const interrupt = () => { interrupted = 130; controller.abort() }
|
|
173
|
+
const terminate = () => { interrupted = 143; controller.abort() }
|
|
174
|
+
const program = Effect.gen(function*() {
|
|
175
|
+
const timeoutMs = yield* Effect.try({ try: datasetTransferTimeoutMs, catch: cause => new AppendFailure(cause instanceof Error ? cause.message : 'Invalid append configuration', 'not_sent') })
|
|
176
|
+
const input = yield* Effect.tryPromise({ try: resolveInput, catch: cause => new AppendFailure(cause instanceof Error ? cause.message : 'Could not resolve append input', 'not_sent') })
|
|
177
|
+
progress.datasetId = input.datasetId
|
|
178
|
+
// Retained interactive selection does not accept a signal. Preserve
|
|
179
|
+
// natural Node termination until we own cancellable source/transport work.
|
|
180
|
+
return yield* Effect.acquireUseRelease(
|
|
181
|
+
Effect.sync(() => { process.once('SIGINT', interrupt); process.once('SIGTERM', terminate) }),
|
|
182
|
+
() => appendProgram(input, timeoutMs, progress, io, controller.signal),
|
|
183
|
+
() => Effect.sync(() => { process.removeListener('SIGINT', interrupt); process.removeListener('SIGTERM', terminate) }),
|
|
184
|
+
)
|
|
185
|
+
})
|
|
186
|
+
const report = (message: string) => {
|
|
187
|
+
const error = sanitizeTerminalText(message)
|
|
188
|
+
const advice = recovery(progress)
|
|
189
|
+
io.print(io.json ? JSON.stringify({ error, datasetId: progress.datasetId, confirmedRows: progress.confirmedRows, confirmedChunks: progress.confirmedChunks, failedChunk: progress.chunk, recovery: advice })
|
|
190
|
+
: `${error}\n${progress.confirmedRows} rows from ${progress.confirmedChunks} chunk(s) were already appended. ${advice}`)
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const result = await Effect.runPromise(Effect.result(program), { signal: controller.signal })
|
|
194
|
+
if (Result.isFailure(result)) {
|
|
195
|
+
if (progress.chunk) progress.chunk.outcome = result.failure.outcome
|
|
196
|
+
report(result.failure.message)
|
|
197
|
+
return 1
|
|
198
|
+
}
|
|
199
|
+
io.print(io.json ? JSON.stringify(result.success) : `Appended ${result.success.appendedCount} rows to dataset ${sanitizeTerminalText(result.success.dataset.name)} (${sanitizeTerminalText(result.success.dataset.id)}). New row count: ${result.success.dataset.rowCount}`)
|
|
200
|
+
return 0
|
|
201
|
+
} catch {
|
|
202
|
+
report(interrupted ? 'Dataset append interrupted' : 'Dataset append failed')
|
|
203
|
+
return interrupted ?? 1
|
|
204
|
+
}
|
|
205
|
+
}
|