orizu 0.5.37 → 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.
Files changed (98) hide show
  1. package/README.md +18 -3
  2. package/dist/app-commands.d.ts.map +1 -1
  3. package/dist/app-commands.js +215 -57
  4. package/dist/app-publish-failure.d.ts +6 -0
  5. package/dist/app-publish-failure.d.ts.map +1 -0
  6. package/dist/app-publish-failure.js +9 -0
  7. package/dist/app-publish-paths.d.ts +4 -0
  8. package/dist/app-publish-paths.d.ts.map +1 -0
  9. package/dist/app-publish-paths.js +32 -0
  10. package/dist/app-publish-workspace.d.ts +16 -0
  11. package/dist/app-publish-workspace.d.ts.map +1 -0
  12. package/dist/app-publish-workspace.js +175 -0
  13. package/dist/artifacts-git-runtime.d.ts +5 -0
  14. package/dist/artifacts-git-runtime.d.ts.map +1 -1
  15. package/dist/artifacts-git-runtime.js +70 -25
  16. package/dist/artifacts-session-merge.d.ts +62 -0
  17. package/dist/artifacts-session-merge.d.ts.map +1 -0
  18. package/dist/artifacts-session-merge.js +123 -0
  19. package/dist/dataset-append-workflow.d.ts +12 -0
  20. package/dist/dataset-append-workflow.d.ts.map +1 -0
  21. package/dist/dataset-append-workflow.js +198 -0
  22. package/dist/dataset-commands.d.ts.map +1 -1
  23. package/dist/dataset-commands.js +11 -130
  24. package/dist/dataset-transfer-download.js +6 -6
  25. package/dist/dataset-transfer-upload.js +8 -8
  26. package/dist/git-credential.d.ts +1 -1
  27. package/dist/git-credential.d.ts.map +1 -1
  28. package/dist/git-credential.js +8 -1
  29. package/dist/help.js +3 -3
  30. package/dist/hosted-boot.d.ts +11 -0
  31. package/dist/hosted-boot.d.ts.map +1 -1
  32. package/dist/hosted-boot.js +46 -1
  33. package/dist/hosted-harvest.d.ts.map +1 -1
  34. package/dist/hosted-harvest.js +11 -1
  35. package/dist/hosted-session-cli.d.ts.map +1 -1
  36. package/dist/hosted-session-cli.js +6 -1
  37. package/dist/hosted-session-seed.d.ts +45 -0
  38. package/dist/hosted-session-seed.d.ts.map +1 -0
  39. package/dist/hosted-session-seed.js +61 -0
  40. package/dist/hosted-skill-staging.d.ts +8 -3
  41. package/dist/hosted-skill-staging.d.ts.map +1 -1
  42. package/dist/hosted-skill-staging.js +10 -3
  43. package/dist/jsonl-stream.d.ts +1 -0
  44. package/dist/jsonl-stream.d.ts.map +1 -1
  45. package/dist/jsonl-stream.js +7 -0
  46. package/dist/provider-list-workflow.d.ts +3 -0
  47. package/dist/provider-list-workflow.d.ts.map +1 -0
  48. package/dist/provider-list-workflow.js +95 -0
  49. package/dist/provider-wire-record.d.ts +3 -0
  50. package/dist/provider-wire-record.d.ts.map +1 -0
  51. package/dist/provider-wire-record.js +4 -0
  52. package/dist/providers-cli.d.ts.map +1 -1
  53. package/dist/providers-cli.js +5 -15
  54. package/dist/publish-merge-job.d.ts +68 -0
  55. package/dist/publish-merge-job.d.ts.map +1 -0
  56. package/dist/publish-merge-job.js +320 -0
  57. package/dist/publish-merge-timing.d.ts +18 -0
  58. package/dist/publish-merge-timing.d.ts.map +1 -0
  59. package/dist/publish-merge-timing.js +23 -0
  60. package/dist/workbench-cli.d.ts +4 -0
  61. package/dist/workbench-cli.d.ts.map +1 -1
  62. package/dist/workbench-cli.js +11 -1
  63. package/dist/workspace-bootstrap-job.d.ts +1 -0
  64. package/dist/workspace-bootstrap-job.d.ts.map +1 -1
  65. package/dist/workspace-bootstrap-job.js +1 -1
  66. package/package.json +5 -2
  67. package/src/app-commands.ts +234 -66
  68. package/src/app-publish-failure.ts +7 -0
  69. package/src/app-publish-paths.ts +27 -0
  70. package/src/app-publish-workspace.ts +207 -0
  71. package/src/artifacts-git-runtime.ts +73 -31
  72. package/src/artifacts-session-merge.ts +253 -0
  73. package/src/dataset-append-workflow.ts +205 -0
  74. package/src/dataset-commands.ts +10 -170
  75. package/src/dataset-transfer-download.ts +5 -5
  76. package/src/dataset-transfer-upload.ts +7 -7
  77. package/src/git-credential.ts +8 -1
  78. package/src/help.ts +3 -3
  79. package/src/hosted-boot.ts +56 -1
  80. package/src/hosted-harvest.ts +11 -1
  81. package/src/hosted-session-cli.ts +6 -1
  82. package/src/hosted-session-seed.ts +78 -0
  83. package/src/hosted-skill-staging.ts +11 -3
  84. package/src/jsonl-stream.ts +8 -0
  85. package/src/provider-list-workflow.ts +108 -0
  86. package/src/provider-wire-record.ts +4 -0
  87. package/src/providers-cli.ts +5 -13
  88. package/src/publish-merge-job.ts +350 -0
  89. package/src/publish-merge-timing.ts +27 -0
  90. package/src/workbench-cli.ts +15 -1
  91. package/src/workspace-bootstrap-job.ts +1 -1
  92. package/vendor/skills/orizu/flows/onboard.md +1 -1
  93. package/vendor/skills/orizu/references/authority-map.md +2 -2
  94. package/vendor/skills/orizu/references/building-apps.md +24 -0
  95. package/vendor/skills/orizu/references/cli-reference.md +13 -4
  96. package/vendor/skills/orizu/references/optimization-reports.md +1 -1
  97. package/vendor/skills/orizu/references/primer.md +1 -0
  98. 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 { chmod, mkdir, rm, writeFile } from 'node:fs/promises'
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
- IFS= read -r password <&3 || exit 1
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
- export const defaultGitRunner: GitRunner = (
208
- args,
209
- { cwd, auth, env: suppliedEnv, commitIdentityEnv, timeoutMs = DEFAULT_GIT_TIMEOUT_MS, signal }
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: auth ? ['ignore', 'pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
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
+ }