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.
- 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-harvest.d.ts.map +1 -1
- package/dist/hosted-harvest.js +11 -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/hosted-skill-staging.d.ts +8 -3
- package/dist/hosted-skill-staging.d.ts.map +1 -1
- package/dist/hosted-skill-staging.js +10 -3
- 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-harvest.ts +11 -1
- package/src/hosted-session-cli.ts +6 -1
- package/src/hosted-session-seed.ts +78 -0
- package/src/hosted-skill-staging.ts +11 -3
- 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
|
@@ -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":"
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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"}
|