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
@@ -0,0 +1,175 @@
1
+ /** ORI-2280 (2280-B2): in a hosted session, an app is published from git. The
2
+ * CLI writes the app into its folder in the workspace, commits those three files
3
+ * alone, and pushes the session copy; the web app then merges that commit into
4
+ * the team copy before it records the version. Written with Effect: git, the
5
+ * files and the web app are services, and each way this can stop is a tagged
6
+ * failure. It holds no key (the clone's credential helper asks for one per git
7
+ * command) and makes no temporary folder, so it has nothing to scope. */
8
+ import { spawn } from 'node:child_process';
9
+ import { mkdirSync, writeFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { Context, Data, Effect, Layer } from 'effect';
12
+ import { isValidCloudflareArtifactsGitRemote } from './cloudflare-artifacts-git-remote.js';
13
+ import { AppPublishFailure } from './app-publish-failure.js';
14
+ import { inspectAppDestination } from './app-publish-paths.js';
15
+ import { resolveAttachedWorkspaceId } from './git-credential.js';
16
+ import { authedFetch, getBaseUrl } from './http.js';
17
+ /** The session copy's branch, which the publish merge reads (ORI-2279). */
18
+ const SESSION_BRANCH_REF = 'refs/heads/main';
19
+ const TEAM_REMOTE_NAME = 'orizu-team';
20
+ const PROJECT_SLUG = /^[a-z0-9][a-z0-9-]{0,99}$/;
21
+ const APP_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
22
+ /** Where an app lives in the team repository. */
23
+ function appFolder(projectSlug, appId) {
24
+ if (!PROJECT_SLUG.test(projectSlug))
25
+ throw new Error(`Invalid project slug for the app folder: ${projectSlug}`);
26
+ if (!APP_ID.test(appId))
27
+ throw new Error(`Invalid app id for the app folder: ${appId}`);
28
+ return `projects/${projectSlug}/apps/${appId}`;
29
+ }
30
+ // -- Failures --------------------------------------------------------------------
31
+ class PushRejected extends Data.TaggedError('PushRejected') {
32
+ }
33
+ class GitFailed extends Data.TaggedError('GitFailed') {
34
+ }
35
+ class TeamRemoteUnavailable extends Data.TaggedError('TeamRemoteUnavailable') {
36
+ }
37
+ class WorkspaceGit extends Context.Service()('WorkspaceGit') {
38
+ }
39
+ class WorkspaceFiles extends Context.Service()('WorkspaceFiles') {
40
+ }
41
+ class PublishApi extends Context.Service()('PublishApi') {
42
+ }
43
+ function liveLayer(root, attempt) {
44
+ const serverOrigin = getBaseUrl();
45
+ const teamRemoteHelper = `!orizu --server '${serverOrigin.replace(/'/g, "'\\''")}' git-credential --purpose=team_read`;
46
+ const git = WorkspaceGit.of({
47
+ run: (args) => Effect.callback((resume) => {
48
+ const child = spawn('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
49
+ let stdout = '';
50
+ let stderr = '';
51
+ child.stdout.on('data', (chunk) => (stdout += chunk));
52
+ child.stderr.on('data', (chunk) => (stderr += chunk));
53
+ child.on('error', (error) => resume(Effect.succeed({ code: 127, stdout, stderr: error.message })));
54
+ child.on('close', (code) => resume(Effect.succeed({ code: code ?? 1, stdout, stderr })));
55
+ }),
56
+ });
57
+ const files = WorkspaceFiles.of({
58
+ write: (files) => Effect.try({
59
+ try: () => {
60
+ for (const file of files)
61
+ inspectAppDestination(root, file.path);
62
+ for (const file of files) {
63
+ mkdirSync(join(root, file.path, '..'), { recursive: true });
64
+ writeFileSync(join(root, file.path), file.content);
65
+ }
66
+ if (attempt)
67
+ attempt.filesMaterialized = true;
68
+ },
69
+ catch: (error) => new GitFailed({ step: 'write the app files', detail: String(error) }),
70
+ }),
71
+ });
72
+ const api = PublishApi.of({
73
+ teamRemoteHelper,
74
+ teamReadRemote: (workspaceId) => Effect.tryPromise({
75
+ try: async (signal) => {
76
+ const response = await authedFetch(`/api/cli/workspaces/${encodeURIComponent(workspaceId)}/repo-token`, {
77
+ method: 'POST',
78
+ headers: { 'Content-Type': 'application/json' },
79
+ body: JSON.stringify({ purpose: 'team_read', sessionId: process.env.ORIZU_SESSION_ID?.trim() }),
80
+ signal,
81
+ });
82
+ if (!response.ok)
83
+ throw new Error(`the repository key service answered ${response.status}`);
84
+ const remote = (await response.json()).remote;
85
+ if (!isValidCloudflareArtifactsGitRemote(remote))
86
+ throw new Error('the repository key service named no team copy');
87
+ return remote;
88
+ },
89
+ catch: (error) => new TeamRemoteUnavailable({ reason: error instanceof Error ? error.message : String(error) }),
90
+ }),
91
+ });
92
+ return Layer.mergeAll(Layer.succeed(WorkspaceGit, git), Layer.succeed(WorkspaceFiles, files), Layer.succeed(PublishApi, api));
93
+ }
94
+ const gitOrFail = (step, args) => Effect.flatMap(WorkspaceGit, (git) => git.run(args)).pipe(Effect.filterOrFail((result) => result.code === 0, (result) => new GitFailed({ step, detail: (result.stderr || result.stdout).trim() })));
95
+ // -- Commit and push -------------------------------------------------------------
96
+ /** Writes the app into its folder, commits those three files alone (other work in
97
+ * the workspace stays as it is), and pushes the session copy. Answers the
98
+ * commit the web app should publish: the new one, or HEAD if nothing changed. */
99
+ const commitAndPush = (folder, files, appId) => Effect.gen(function* () {
100
+ const workspaceFiles = yield* WorkspaceFiles;
101
+ yield* workspaceFiles.write([
102
+ { path: `${folder}/App.tsx`, content: files.sourceCode },
103
+ { path: `${folder}/input.schema.json`, content: files.inputSchema },
104
+ { path: `${folder}/output.schema.json`, content: files.outputSchema },
105
+ ]);
106
+ const paths = ['App.tsx', 'input.schema.json', 'output.schema.json'].map(filename => `${folder}/${filename}`);
107
+ yield* gitOrFail('stage the app files', ['add', '--', ...paths]);
108
+ const git = yield* WorkspaceGit;
109
+ // Exit 1: the three app files differ from HEAD.
110
+ const changed = yield* git.run(['diff', '--cached', '--quiet', 'HEAD', '--', ...paths]);
111
+ if (changed.code === 1) {
112
+ yield* gitOrFail('commit the app files', ['commit', '-q', '-m', `Publish app ${appId}`, '--', ...paths]);
113
+ }
114
+ else if (changed.code !== 0) {
115
+ return yield* new GitFailed({ step: 'compare the app folder', detail: changed.stderr.trim() });
116
+ }
117
+ const sha = (yield* gitOrFail('read the commit', ['rev-parse', 'HEAD'])).stdout.trim();
118
+ const pushed = yield* git.run(['push', '-q', 'origin', `HEAD:${SESSION_BRANCH_REF}`]);
119
+ if (pushed.code !== 0) {
120
+ if (/\[rejected\]|non-fast-forward|fetch first/.test(pushed.stderr))
121
+ return yield* new PushRejected();
122
+ return yield* new GitFailed({ step: 'push the session copy', detail: pushed.stderr.trim() });
123
+ }
124
+ return sha;
125
+ });
126
+ /** Commits and pushes the app, answering the commit to publish. Throws the
127
+ * message the agent should act on. */
128
+ export async function pushAppToSessionCopy(root, input) {
129
+ const program = commitAndPush(appFolder(input.projectSlug, input.appId), input.files, input.appId).pipe(Effect.catchTags({
130
+ PushRejected: () => Effect.fail(new Error('Publish stopped: your session copy has commits this workspace does not. Run "git pull --no-rebase --no-edit" in the workspace, then run this command again.')),
131
+ GitFailed: (failure) => Effect.fail(new Error(`Publish stopped before anything was sent: could not ${failure.step} (${failure.detail}).`)),
132
+ }));
133
+ const attempt = { filesMaterialized: false };
134
+ try {
135
+ return await Effect.runPromise(Effect.provide(program, liveLayer(root, attempt)));
136
+ }
137
+ catch (error) {
138
+ throw new AppPublishFailure(error instanceof Error ? error.message : String(error), attempt.filesMaterialized);
139
+ }
140
+ }
141
+ // -- The team copy, after a conflict ---------------------------------------------
142
+ /** Adds the `orizu-team` remote so the agent can pull the team copy and fix a
143
+ * conflict. It reads with its own team_read key: an empty helper entry first
144
+ * stops the session copy's helper from answering for it. Refreshes the URL and
145
+ * helper each time so interrupted recovery or an old remote is repaired. */
146
+ const addTeamRemote = (workspaceId) => Effect.gen(function* () {
147
+ const git = yield* WorkspaceGit;
148
+ const hasRemote = (yield* git.run(['remote', 'get-url', TEAM_REMOTE_NAME])).code === 0;
149
+ if (!workspaceId)
150
+ return yield* new TeamRemoteUnavailable({ reason: 'this workspace names no Orizu workspace id' });
151
+ const api = yield* PublishApi;
152
+ const remote = yield* api.teamReadRemote(workspaceId);
153
+ const failed = (step) => (failure) => new TeamRemoteUnavailable({ reason: `${step}: ${failure.detail}` });
154
+ const remoteAction = hasRemote ? 'set-url' : 'add';
155
+ yield* gitOrFail(remoteAction, ['remote', remoteAction, TEAM_REMOTE_NAME, remote]).pipe(Effect.mapError(failed(`git remote ${remoteAction}`)));
156
+ const cleared = yield* git.run(['config', '--unset-all', `credential.${remote}.helper`]);
157
+ // Git exits 5 when there was no matching key, which is normal on first use.
158
+ if (cleared.code !== 0 && cleared.code !== 5) {
159
+ return yield* new TeamRemoteUnavailable({ reason: `git config: ${(cleared.stderr || cleared.stdout).trim()}` });
160
+ }
161
+ for (const [operation, key, value] of [
162
+ ['--add', `credential.${remote}.helper`, ''],
163
+ ['--add', `credential.${remote}.helper`, api.teamRemoteHelper],
164
+ ['--replace-all', `credential.${remote}.useHttpPath`, 'true'],
165
+ ]) {
166
+ yield* gitOrFail('config', ['config', operation, key, value]).pipe(Effect.mapError(failed('git config')));
167
+ }
168
+ });
169
+ /** Adds the team copy remote; answers a sentence to add to the conflict
170
+ * message when it could not, else null. */
171
+ export function addTeamCopyRemote(root) {
172
+ const workspaceId = process.env.ORIZU_WORKSPACE_ID?.trim() || resolveAttachedWorkspaceId(root);
173
+ const program = addTeamRemote(workspaceId).pipe(Effect.as(null), Effect.catchTag('TeamRemoteUnavailable', (failure) => Effect.succeed(`The ${TEAM_REMOTE_NAME} remote could not be added (${failure.reason}); add it with "git remote add ${TEAM_REMOTE_NAME} <team copy>".`)));
174
+ return Effect.runPromise(Effect.provide(program, liveLayer(root)));
175
+ }
@@ -28,6 +28,11 @@ export declare function createEphemeralAskPass(root: string): Promise<{
28
28
  }>;
29
29
  export declare function buildGitAuthEnvironment(auth: GitAuth, base?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
30
30
  export declare function assertSafeGitInvocation(args: readonly string[], secrets?: readonly string[]): void;
31
+ /** Runs one git command. With auth, the key goes in a 0600 file inside a
32
+ * fresh 0700 folder, named to that git process only by ORIZU_GIT_KEY_FILE.
33
+ * Askpass reads it from there. The folder is removed when the command
34
+ * settles, however it ends: success, failure, timeout or cancel; and if this
35
+ * process ends first, on its way out (see liveKeyDirs). */
31
36
  export declare const defaultGitRunner: GitRunner;
32
37
  export declare function gitOk(runGit: GitRunner, args: string[], options: GitRunOptions, operation: string): Promise<string>;
33
38
  export declare function revParse(runGit: GitRunner, cwd: string, ref: string): Promise<string>;
@@ -1 +1 @@
1
- {"version":3,"file":"artifacts-git-runtime.d.ts","sourceRoot":"","sources":["../src/artifacts-git-runtime.ts"],"names":[],"mappings":"AAoCA,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB;AAED,MAAM,WAAW,OAAO;IACtB,WAAW,EAAE,MAAM,CAAA;IACnB,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAA;IACvB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB,iBAAiB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CACrD;AAED,MAAM,MAAM,SAAS,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,aAAa,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAAA;AAE7F,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,IAAI,CASvF;AAED,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAkBrE;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,SAAS,CAAC,MAAM,GAAG,SAAS,CAAC,EAAE,GAAG,MAAM,EAAE,CAS3F;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,CAAC,MAAM,GAAG,SAAS,CAAC,EAAE,GAAG,MAAM,CAK7F;AAED,wBAAsB,sBAAsB,CAC1C,IAAI,EAAE,MAAM,GACX,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CAAC,CAsBzD;AAUD,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,OAAO,EACb,IAAI,GAAE,MAAM,CAAC,UAAwB,GACpC,MAAM,CAAC,UAAU,CAkBnB;AAgBD,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,GAAE,SAAS,MAAM,EAAO,GAC9B,IAAI,CAYN;AAWD,eAAO,MAAM,gBAAgB,EAAE,SAoI3B,CAAA;AAEJ,wBAAsB,KAAK,CACzB,MAAM,EAAE,SAAS,EACjB,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,aAAa,EACtB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,MAAM,CAAC,CAUjB;AAED,wBAAsB,QAAQ,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAE3F"}
1
+ {"version":3,"file":"artifacts-git-runtime.d.ts","sourceRoot":"","sources":["../src/artifacts-git-runtime.ts"],"names":[],"mappings":"AAuCA,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB;AAED,MAAM,WAAW,OAAO;IACtB,WAAW,EAAE,MAAM,CAAA;IACnB,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAA;IACvB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB,iBAAiB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CACrD;AAED,MAAM,MAAM,SAAS,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,aAAa,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAAA;AAE7F,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,IAAI,CASvF;AAED,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAkBrE;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,SAAS,CAAC,MAAM,GAAG,SAAS,CAAC,EAAE,GAAG,MAAM,EAAE,CAS3F;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,CAAC,MAAM,GAAG,SAAS,CAAC,EAAE,GAAG,MAAM,CAK7F;AAED,wBAAsB,sBAAsB,CAC1C,IAAI,EAAE,MAAM,GACX,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CAAC,CAuBzD;AAUD,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,OAAO,EACb,IAAI,GAAE,MAAM,CAAC,UAAwB,GACpC,MAAM,CAAC,UAAU,CAkBnB;AAgBD,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,GAAE,SAAS,MAAM,EAAO,GAC9B,IAAI,CAYN;AAiDD;;;;2DAI2D;AAC3D,eAAO,MAAM,gBAAgB,EAAE,SAc9B,CAAA;AAmHD,wBAAsB,KAAK,CACzB,MAAM,EAAE,SAAS,EACjB,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,aAAa,EACtB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,MAAM,CAAC,CAUjB;AAED,wBAAsB,QAAQ,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAE3F"}
@@ -1,11 +1,15 @@
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
6
  const ARTIFACT_TOKEN_BODY = /^[^\s\u0000-\u001f\u007f]{16,1024}$/;
5
7
  const ARTIFACT_TOKEN_ADVISORY_SUFFIX = '?expires=';
6
8
  const ARTIFACT_TOKEN_BARE_BODY = /art_v1_[0-9a-fA-F]{40}/;
7
9
  const DEFAULT_GIT_TIMEOUT_MS = 30_000;
8
10
  const GIT_TERMINATION_GRACE_MS = 1_000;
11
+ /** Names the one-use key file, set on the git process that needs it and nowhere else. */
12
+ const GIT_KEY_FILE_ENV = 'ORIZU_GIT_KEY_FILE';
9
13
  const GIT_CONTEXT_ENV_KEYS = [
10
14
  'GIT_DIR',
11
15
  'GIT_WORK_TREE',
@@ -85,7 +89,8 @@ export async function createEphemeralAskPass(root) {
85
89
  case "$1" in
86
90
  *sername*) printf '%s\\n' x ;;
87
91
  *assword*)
88
- IFS= read -r password <&3 || exit 1
92
+ [ -n "$${GIT_KEY_FILE_ENV}" ] || exit 1
93
+ IFS= read -r password < "$${GIT_KEY_FILE_ENV}" || exit 1
89
94
  [ -n "$password" ] || exit 1
90
95
  printf '%s\\n' "$password"
91
96
  ;;
@@ -165,7 +170,67 @@ function signalGitProcess(child, signal) {
165
170
  // The process may have exited between the state check and the signal.
166
171
  }
167
172
  }
168
- export const defaultGitRunner = (args, { cwd, auth, env: suppliedEnv, commitIdentityEnv, timeoutMs = DEFAULT_GIT_TIMEOUT_MS, signal }) => new Promise((resolve, reject) => {
173
+ /** Key folders this process holds right now. An abrupt end of the process
174
+ * skips the runner's own cleanup, so they are also removed, best effort, on
175
+ * exit, and on a termination signal that ends the process. A script that
176
+ * handles the signal itself keeps its keys: its running command may still need
177
+ * them, and the exit listener covers it later. SIGKILL cannot be caught: a
178
+ * folder it leaves behind holds a key that still expires on its own. */
179
+ const liveKeyDirs = new Set();
180
+ let isExitCleanupInstalled = false;
181
+ // SIGHUP and SIGQUIT run no exit handlers either; operator scripts run over SSH.
182
+ const TERMINATION_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'];
183
+ function removeLiveKeyDirs() {
184
+ for (const dir of liveKeyDirs) {
185
+ try {
186
+ rmSync(dir, { recursive: true, force: true });
187
+ }
188
+ catch {
189
+ // Best effort: the process is ending.
190
+ }
191
+ }
192
+ liveKeyDirs.clear();
193
+ }
194
+ function handleTerminationSignal(signal) {
195
+ // Another listener decides whether the process ends; leave the keys to it.
196
+ if (process.listenerCount(signal) !== 1)
197
+ return;
198
+ removeLiveKeyDirs();
199
+ // End the way the signal would have without this listener.
200
+ process.off(signal, handleTerminationSignal);
201
+ process.kill(process.pid, signal);
202
+ }
203
+ function installExitCleanup() {
204
+ if (isExitCleanupInstalled)
205
+ return;
206
+ isExitCleanupInstalled = true;
207
+ process.on('exit', removeLiveKeyDirs);
208
+ for (const signal of TERMINATION_SIGNALS)
209
+ process.on(signal, handleTerminationSignal);
210
+ }
211
+ /** Runs one git command. With auth, the key goes in a 0600 file inside a
212
+ * fresh 0700 folder, named to that git process only by ORIZU_GIT_KEY_FILE.
213
+ * Askpass reads it from there. The folder is removed when the command
214
+ * settles, however it ends: success, failure, timeout or cancel; and if this
215
+ * process ends first, on its way out (see liveKeyDirs). */
216
+ export const defaultGitRunner = async (args, options) => {
217
+ if (!options.auth)
218
+ return runGitProcess(args, options, null);
219
+ installExitCleanup();
220
+ const keyDir = await mkdtemp(join(tmpdir(), 'orizu-git-key-'));
221
+ liveKeyDirs.add(keyDir);
222
+ try {
223
+ await chmod(keyDir, 0o700);
224
+ const keyFile = join(keyDir, 'key');
225
+ await writeFile(keyFile, `${options.auth.token}\n`, { mode: 0o600, flag: 'wx' });
226
+ return await runGitProcess(args, options, keyFile);
227
+ }
228
+ finally {
229
+ await rm(keyDir, { recursive: true, force: true });
230
+ liveKeyDirs.delete(keyDir);
231
+ }
232
+ };
233
+ const runGitProcess = (args, { cwd, auth, env: suppliedEnv, commitIdentityEnv, timeoutMs = DEFAULT_GIT_TIMEOUT_MS, signal }, keyFile) => new Promise((resolve, reject) => {
169
234
  if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
170
235
  reject(new Error('Git timeout must be a positive integer'));
171
236
  return;
@@ -182,7 +247,7 @@ export const defaultGitRunner = (args, { cwd, auth, env: suppliedEnv, commitIden
182
247
  return;
183
248
  }
184
249
  const baseEnv = auth
185
- ? buildGitAuthEnvironment(auth, suppliedEnv)
250
+ ? { ...buildGitAuthEnvironment(auth, suppliedEnv), ...(keyFile ? { [GIT_KEY_FILE_ENV]: keyFile } : {}) }
186
251
  : buildUnauthenticatedGitEnvironment(suppliedEnv);
187
252
  let env = baseEnv;
188
253
  if (commitIdentityEnv) {
@@ -194,7 +259,7 @@ export const defaultGitRunner = (args, { cwd, auth, env: suppliedEnv, commitIden
194
259
  cwd,
195
260
  env,
196
261
  detached: process.platform !== 'win32',
197
- stdio: auth ? ['ignore', 'pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
262
+ stdio: ['ignore', 'pipe', 'pipe'],
198
263
  });
199
264
  let stdout = '';
200
265
  let stderr = '';
@@ -273,26 +338,6 @@ export const defaultGitRunner = (args, { cwd, auth, env: suppliedEnv, commitIden
273
338
  child.on('close', (code) => {
274
339
  finish(termination === 'timeout' ? 124 : termination === 'cancelled' ? 130 : (code ?? 1));
275
340
  });
276
- if (auth) {
277
- const credentialPipe = child.stdio[3];
278
- if (!credentialPipe) {
279
- fail(new Error('Could not establish one-use Git credential pipe'));
280
- return;
281
- }
282
- credentialPipe.on('error', () => {
283
- /* EPIPE if Git fails before auth. */
284
- });
285
- if (termination) {
286
- credentialPipe.destroy();
287
- return;
288
- }
289
- try {
290
- credentialPipe.end(`${auth.token}\n`);
291
- }
292
- catch (error) {
293
- fail(error instanceof Error ? error : new Error('Could not write the one-use Git credential'));
294
- }
295
- }
296
341
  });
297
342
  export async function gitOk(runGit, args, options, operation) {
298
343
  const result = await runGit(args, options);
@@ -0,0 +1,62 @@
1
+ import { type GitAuth, type GitRunner } from './artifacts-git-runtime.js';
2
+ interface SessionMergeEndpoint {
3
+ readonly remote: string;
4
+ /** Read access. Omitted only for local remotes in tests. */
5
+ readonly auth?: GitAuth;
6
+ }
7
+ interface PreparedSessionMerge {
8
+ readonly previousCanonicalSha: string;
9
+ readonly sourceSha: string;
10
+ readonly mergeSha: string;
11
+ readonly attempt: number;
12
+ /** The merge folder: both copies' fetched objects plus the merge commit. */
13
+ readonly worktree: string;
14
+ }
15
+ type SessionMergePushResult = 'pushed' | 'stale';
16
+ export interface PublishSessionMergeInput {
17
+ /** A writable folder; each attempt merges in its own temporary subfolder. */
18
+ readonly root: string;
19
+ readonly canonical: SessionMergeEndpoint;
20
+ readonly source: SessionMergeEndpoint;
21
+ readonly canonicalRef: string;
22
+ readonly sourceRef: string;
23
+ /** The session head the publish was approved for. */
24
+ readonly expectedSourceSha: string;
25
+ readonly commitMessage: string;
26
+ readonly commitIdentityEnv: Readonly<Record<string, string>>;
27
+ /** The final permission check, asked after every merge and before any write. */
28
+ readonly authorize: (merge: PreparedSessionMerge) => Promise<boolean>;
29
+ /**
30
+ * Mints the one-time team-copy write key, runs `push` once with it, then
31
+ * revokes it. `undefined` is allowed only for local remotes in tests.
32
+ */
33
+ readonly withCanonicalWrite: (push: (auth: GitAuth | undefined) => Promise<SessionMergePushResult>) => Promise<SessionMergePushResult>;
34
+ readonly timeoutMs?: number;
35
+ readonly signal?: AbortSignal;
36
+ readonly runGit?: GitRunner;
37
+ }
38
+ export type PublishSessionMergeResult = {
39
+ readonly kind: 'merged';
40
+ readonly previousCanonicalSha: string;
41
+ readonly sourceSha: string;
42
+ readonly mergeSha: string;
43
+ readonly attempts: number;
44
+ } | {
45
+ /** The session head is already in the team copy: nothing to push. */
46
+ readonly kind: 'already_merged';
47
+ readonly canonicalSha: string;
48
+ readonly sourceSha: string;
49
+ } | {
50
+ readonly kind: 'conflict';
51
+ readonly files: readonly string[];
52
+ readonly canonicalSha: string;
53
+ readonly sourceSha: string;
54
+ } | {
55
+ readonly kind: 'denied';
56
+ } | {
57
+ readonly kind: 'race_lost';
58
+ readonly attempts: number;
59
+ };
60
+ export declare function publishSessionMerge(input: PublishSessionMergeInput): Promise<PublishSessionMergeResult>;
61
+ export {};
62
+ //# sourceMappingURL=artifacts-session-merge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"artifacts-session-merge.d.ts","sourceRoot":"","sources":["../src/artifacts-session-merge.ts"],"names":[],"mappings":"AAGA,OAAO,EAKL,KAAK,OAAO,EACZ,KAAK,SAAS,EACf,MAAM,4BAA4B,CAAA;AAiBnC,UAAU,oBAAoB;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,4DAA4D;IAC5D,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CACxB;AAED,UAAU,oBAAoB;IAC5B,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAA;IACrC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,4EAA4E;IAC5E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAC1B;AAED,KAAK,sBAAsB,GAAG,QAAQ,GAAG,OAAO,CAAA;AAEhD,MAAM,WAAW,wBAAwB;IACvC,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,SAAS,EAAE,oBAAoB,CAAA;IACxC,QAAQ,CAAC,MAAM,EAAE,oBAAoB,CAAA;IACrC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,qDAAqD;IACrD,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAA;IAClC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,QAAQ,CAAC,iBAAiB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IAC5D,gFAAgF;IAChF,QAAQ,CAAC,SAAS,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IACrE;;;OAGG;IACH,QAAQ,CAAC,kBAAkB,EAAE,CAC3B,IAAI,EAAE,CAAC,IAAI,EAAE,OAAO,GAAG,SAAS,KAAK,OAAO,CAAC,sBAAsB,CAAC,KACjE,OAAO,CAAC,sBAAsB,CAAC,CAAA;IACpC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAA;IAC7B,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAA;CAC5B;AAED,MAAM,MAAM,yBAAyB,GACjC;IACE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;IACvB,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAA;IACrC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAC1B,GACD;IACE,qEAAqE;IACrE,QAAQ,CAAC,IAAI,EAAE,gBAAgB,CAAA;IAC/B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAC3B,GACD;IACE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAA;IACjC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAC3B,GACD;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,GAC3B;IAAE,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAA;AAM7D,wBAAsB,mBAAmB,CACvC,KAAK,EAAE,wBAAwB,GAC9B,OAAO,CAAC,yBAAyB,CAAC,CAyBpC"}
@@ -0,0 +1,123 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { defaultGitRunner, gitOk, redactSecrets, revParse, } from './artifacts-git-runtime.js';
4
+ /**
5
+ * The publisher's git step (ORI-2279): merge a session copy into the team
6
+ * copy, ask for permission, then push with compare-and-swap and read back.
7
+ *
8
+ * Runs in a server-owned process with `git` on PATH (the coordinator's merge
9
+ * job), never in an agent's sandbox. It reads no environment and no
10
+ * database: the caller's callbacks carry the permission answer and the
11
+ * one-time team-copy write key.
12
+ */
13
+ const SESSION_MERGE_MAX_ATTEMPTS = 3;
14
+ const CANONICAL_TRACKING_REF = 'refs/remotes/orizu-canonical/head';
15
+ const SOURCE_TRACKING_REF = 'refs/remotes/orizu-source/head';
16
+ export async function publishSessionMerge(input) {
17
+ const runGit = input.runGit ?? defaultGitRunner;
18
+ for (let attempt = 1; attempt <= SESSION_MERGE_MAX_ATTEMPTS; attempt += 1) {
19
+ const worktree = await mkdtemp(join(input.root, 'session-merge-'));
20
+ try {
21
+ const prepared = await prepareMerge(input, runGit, worktree, attempt);
22
+ if (prepared.kind !== 'merge')
23
+ return prepared;
24
+ if (!(await input.authorize(prepared.merge)))
25
+ return { kind: 'denied' };
26
+ const pushed = await input.withCanonicalWrite((auth) => pushMerge(input, runGit, worktree, prepared.merge, auth));
27
+ if (pushed === 'stale')
28
+ continue;
29
+ await readBack(input, runGit, worktree, prepared.merge.mergeSha);
30
+ return {
31
+ kind: 'merged',
32
+ previousCanonicalSha: prepared.merge.previousCanonicalSha,
33
+ sourceSha: prepared.merge.sourceSha,
34
+ mergeSha: prepared.merge.mergeSha,
35
+ attempts: attempt,
36
+ };
37
+ }
38
+ finally {
39
+ await rm(worktree, { recursive: true, force: true });
40
+ }
41
+ }
42
+ return { kind: 'race_lost', attempts: SESSION_MERGE_MAX_ATTEMPTS };
43
+ }
44
+ async function prepareMerge(input, runGit, worktree, attempt) {
45
+ await gitOk(runGit, ['init', '-q'], { cwd: worktree }, 'initialise merge folder');
46
+ await fetchInto(input, runGit, worktree, input.canonical, input.canonicalRef, CANONICAL_TRACKING_REF);
47
+ await fetchInto(input, runGit, worktree, input.source, input.sourceRef, SOURCE_TRACKING_REF);
48
+ const canonicalSha = await revParse(runGit, worktree, CANONICAL_TRACKING_REF);
49
+ const sourceSha = await revParse(runGit, worktree, SOURCE_TRACKING_REF);
50
+ if (sourceSha !== input.expectedSourceSha) {
51
+ throw new Error('Session copy moved since the publish was approved');
52
+ }
53
+ if (await isAncestor(runGit, worktree, sourceSha, canonicalSha)) {
54
+ return { kind: 'already_merged', canonicalSha, sourceSha };
55
+ }
56
+ await gitOk(runGit, ['checkout', '-q', '--detach', canonicalSha], { cwd: worktree }, 'check out the team copy head');
57
+ const merge = await runGit(['merge', '--no-ff', '--no-edit', '-m', input.commitMessage, sourceSha], {
58
+ cwd: worktree,
59
+ commitIdentityEnv: input.commitIdentityEnv,
60
+ });
61
+ if (merge.exitCode === 1) {
62
+ const unmerged = await gitOk(runGit, ['diff', '--name-only', '--diff-filter=U', '-z'], { cwd: worktree }, 'list conflicted files');
63
+ const files = unmerged.split('\u0000').filter(Boolean);
64
+ if (files.length > 0)
65
+ return { kind: 'conflict', files, canonicalSha, sourceSha };
66
+ }
67
+ if (merge.exitCode !== 0) {
68
+ throw new Error(`merge session into team copy failed: ${(merge.stderr || merge.stdout).trim()}`);
69
+ }
70
+ const mergeSha = await revParse(runGit, worktree, 'HEAD');
71
+ const parents = (await gitOk(runGit, ['rev-list', '--parents', '-n', '1', mergeSha], { cwd: worktree }, 'inspect merge parents'))
72
+ .split(/\s+/)
73
+ .slice(1);
74
+ if (parents.length !== 2 || parents[0] !== canonicalSha || parents[1] !== sourceSha) {
75
+ throw new Error('Merge did not keep the team head and session head as its parents');
76
+ }
77
+ return { kind: 'merge', merge: { previousCanonicalSha: canonicalSha, sourceSha, mergeSha, attempt, worktree } };
78
+ }
79
+ async function pushMerge(input, runGit, worktree, merge, auth) {
80
+ // The lease is the team head this merge was built on, not whatever the
81
+ // remote holds now: if another publish landed after our fetch, the push is
82
+ // refused ("stale info") and the caller merges again on the new head.
83
+ const result = await runGit([
84
+ 'push',
85
+ '--porcelain',
86
+ `--force-with-lease=${input.canonicalRef}:${merge.previousCanonicalSha}`,
87
+ input.canonical.remote,
88
+ `${merge.mergeSha}:${input.canonicalRef}`,
89
+ ], { cwd: worktree, auth, timeoutMs: input.timeoutMs, signal: input.signal });
90
+ if (result.exitCode === 0)
91
+ return 'pushed';
92
+ const output = `${result.stdout}\n${result.stderr}`;
93
+ if (!result.timedOut && !result.cancelled && /\(stale info\)/.test(output))
94
+ return 'stale';
95
+ const detail = redactSecrets(output.trim(), auth ? [auth.token] : []);
96
+ throw new Error(`push merge to team copy failed: ${detail}`);
97
+ }
98
+ /** Proves the push landed: the team head is the merge, or a later commit containing it. */
99
+ async function readBack(input, runGit, worktree, mergeSha) {
100
+ const listed = await gitOk(runGit, ['ls-remote', '--refs', input.canonical.remote, input.canonicalRef], { cwd: worktree, auth: input.canonical.auth, timeoutMs: input.timeoutMs, signal: input.signal }, 'read back the team copy head');
101
+ const [head, ref] = listed.split(/\s+/);
102
+ if (ref !== input.canonicalRef || !/^[0-9a-f]{40}$/.test(head ?? '')) {
103
+ throw new Error('Team copy read-back returned an unexpected answer');
104
+ }
105
+ if (head === mergeSha)
106
+ return;
107
+ await fetchInto(input, runGit, worktree, input.canonical, input.canonicalRef, CANONICAL_TRACKING_REF);
108
+ const fetched = await revParse(runGit, worktree, CANONICAL_TRACKING_REF);
109
+ if (!(await isAncestor(runGit, worktree, mergeSha, fetched))) {
110
+ throw new Error('Team copy read-back does not contain the pushed merge');
111
+ }
112
+ }
113
+ async function fetchInto(input, runGit, worktree, endpoint, ref, trackingRef) {
114
+ await gitOk(runGit, ['fetch', '-q', '--no-tags', endpoint.remote, `+${ref}:${trackingRef}`], { cwd: worktree, auth: endpoint.auth, timeoutMs: input.timeoutMs, signal: input.signal }, `fetch ${ref}`);
115
+ }
116
+ async function isAncestor(runGit, cwd, ancestor, descendant) {
117
+ const result = await runGit(['merge-base', '--is-ancestor', ancestor, descendant], { cwd });
118
+ if (result.exitCode === 0)
119
+ return true;
120
+ if (result.exitCode === 1)
121
+ return false;
122
+ throw new Error(`check ancestry failed: ${result.stderr.trim()}`);
123
+ }
@@ -0,0 +1,12 @@
1
+ export interface DatasetAppendInput {
2
+ datasetId: string;
3
+ file: string;
4
+ }
5
+ interface AppendIo {
6
+ json: boolean;
7
+ print: (message: string) => void;
8
+ }
9
+ /** Owns standalone append's source, admitted writes, progress and recovery. */
10
+ export declare function appendDatasetWorkflow(resolveInput: () => Promise<DatasetAppendInput>, io: AppendIo): Promise<number>;
11
+ export {};
12
+ //# sourceMappingURL=dataset-append-workflow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dataset-append-workflow.d.ts","sourceRoot":"","sources":["../src/dataset-append-workflow.ts"],"names":[],"mappings":"AAYA,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,EAAE,MAAM,CAAA;CACb;AACD,UAAU,QAAQ;IAChB,IAAI,EAAE,OAAO,CAAA;IACb,KAAK,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;CACjC;AAmJD,+EAA+E;AAC/E,wBAAsB,qBAAqB,CAAC,YAAY,EAAE,MAAM,OAAO,CAAC,kBAAkB,CAAC,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,MAAM,CAAC,CAqC1H"}