@nurix/nustack 0.10.0-dev.10

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 (131) hide show
  1. package/README.md +77 -0
  2. package/dist/commands/add.js +130 -0
  3. package/dist/commands/agent.js +100 -0
  4. package/dist/commands/bootstrap.js +54 -0
  5. package/dist/commands/describe.js +34 -0
  6. package/dist/commands/device.js +159 -0
  7. package/dist/commands/docs.js +119 -0
  8. package/dist/commands/doctor.js +284 -0
  9. package/dist/commands/graph.js +34 -0
  10. package/dist/commands/handoff.js +108 -0
  11. package/dist/commands/init.js +280 -0
  12. package/dist/commands/lane.js +12 -0
  13. package/dist/commands/list.js +20 -0
  14. package/dist/commands/login.js +134 -0
  15. package/dist/commands/logout.js +44 -0
  16. package/dist/commands/open.js +54 -0
  17. package/dist/commands/organization.js +114 -0
  18. package/dist/commands/review.js +152 -0
  19. package/dist/commands/search.js +57 -0
  20. package/dist/commands/sessions.js +62 -0
  21. package/dist/commands/sync.js +85 -0
  22. package/dist/commands/telemetry.js +112 -0
  23. package/dist/commands/toolkit.js +167 -0
  24. package/dist/commands/validate.js +67 -0
  25. package/dist/commands/workspace-sync.js +645 -0
  26. package/dist/commands/workspace.js +479 -0
  27. package/dist/index.js +357 -0
  28. package/dist/lib/agent/claudeHooks.js +77 -0
  29. package/dist/lib/auth_grant.js +398 -0
  30. package/dist/lib/auth_recovery.js +49 -0
  31. package/dist/lib/browser_opener.js +24 -0
  32. package/dist/lib/device_identity.js +28 -0
  33. package/dist/lib/device_keychain.js +101 -0
  34. package/dist/lib/env_file.js +75 -0
  35. package/dist/lib/etna/manager.js +43 -0
  36. package/dist/lib/etna/profile.js +41 -0
  37. package/dist/lib/etna/reconcile.js +77 -0
  38. package/dist/lib/filebeat_installer.js +246 -0
  39. package/dist/lib/git-sync/core/commit_range.js +37 -0
  40. package/dist/lib/git-sync/core/engine.js +263 -0
  41. package/dist/lib/git-sync/core/git_runner.js +137 -0
  42. package/dist/lib/git-sync/core/operation_state.js +22 -0
  43. package/dist/lib/git-sync/core/path_normalizer.js +98 -0
  44. package/dist/lib/git-sync/core/reconciliation_lock.js +103 -0
  45. package/dist/lib/git-sync/core/repository.js +49 -0
  46. package/dist/lib/git-sync/core/watch_stream.js +86 -0
  47. package/dist/lib/git-sync/core/watcher.js +61 -0
  48. package/dist/lib/git-sync/inbound/applier.js +131 -0
  49. package/dist/lib/git-sync/inbound/apply_commit.js +225 -0
  50. package/dist/lib/git-sync/inbound/approval.js +117 -0
  51. package/dist/lib/git-sync/inbound/content_decision.js +148 -0
  52. package/dist/lib/git-sync/inbound/download.js +223 -0
  53. package/dist/lib/git-sync/inbound/event_sync.js +133 -0
  54. package/dist/lib/git-sync/inbound/index.js +10 -0
  55. package/dist/lib/git-sync/inbound/notification.js +39 -0
  56. package/dist/lib/git-sync/inbound/state_fingerprint.js +32 -0
  57. package/dist/lib/git-sync/inbound/technical_wait.js +39 -0
  58. package/dist/lib/git-sync/inbound/undo.js +198 -0
  59. package/dist/lib/git-sync/outbound/extraction.js +107 -0
  60. package/dist/lib/git-sync/outbound/history_decision.js +112 -0
  61. package/dist/lib/git-sync/outbound/index.js +26 -0
  62. package/dist/lib/git-sync/outbound/manifest_builder.js +58 -0
  63. package/dist/lib/git-sync/outbound/publisher.js +51 -0
  64. package/dist/lib/git-sync/outbound/queue.js +123 -0
  65. package/dist/lib/git-sync/outbound/state.js +12 -0
  66. package/dist/lib/git-sync/outbound/upload_client.js +141 -0
  67. package/dist/lib/git-sync/recovery/bundle.js +56 -0
  68. package/dist/lib/git-sync/recovery/crypto.js +114 -0
  69. package/dist/lib/git-sync/recovery/index.js +3 -0
  70. package/dist/lib/git-sync/recovery/store.js +55 -0
  71. package/dist/lib/git-sync/whitelist/index.js +4 -0
  72. package/dist/lib/git-sync/whitelist/matcher.js +62 -0
  73. package/dist/lib/git-sync/whitelist/store.js +99 -0
  74. package/dist/lib/git-sync/whitelist/types.js +46 -0
  75. package/dist/lib/git-sync/whitelist/validation.js +60 -0
  76. package/dist/lib/graph/answer.js +171 -0
  77. package/dist/lib/graph/freshness.js +29 -0
  78. package/dist/lib/graph/library.js +19 -0
  79. package/dist/lib/guards.js +106 -0
  80. package/dist/lib/interactive.js +12 -0
  81. package/dist/lib/json_output.js +8 -0
  82. package/dist/lib/lanes/claudeAdapter.js +135 -0
  83. package/dist/lib/lanes/frames.js +73 -0
  84. package/dist/lib/lanes/supervisor.js +247 -0
  85. package/dist/lib/lanes/worktree.js +33 -0
  86. package/dist/lib/local_store/accepted_uploads.js +49 -0
  87. package/dist/lib/local_store/bindings.js +163 -0
  88. package/dist/lib/local_store/commit_origins.js +25 -0
  89. package/dist/lib/local_store/database.js +71 -0
  90. package/dist/lib/local_store/device_state.js +72 -0
  91. package/dist/lib/local_store/filebeat_state.js +133 -0
  92. package/dist/lib/local_store/handoff_applications.js +166 -0
  93. package/dist/lib/local_store/history_decisions.js +48 -0
  94. package/dist/lib/local_store/index.js +14 -0
  95. package/dist/lib/local_store/migrations.js +240 -0
  96. package/dist/lib/local_store/paths.js +24 -0
  97. package/dist/lib/local_store/pending_uploads.js +76 -0
  98. package/dist/lib/local_store/recovery_bundles.js +66 -0
  99. package/dist/lib/local_store/sync_vocabulary.js +33 -0
  100. package/dist/lib/local_store/workspace_events_cache.js +54 -0
  101. package/dist/lib/loopback.js +78 -0
  102. package/dist/lib/manifest.js +83 -0
  103. package/dist/lib/nustack_client.js +547 -0
  104. package/dist/lib/nustack_store.js +17 -0
  105. package/dist/lib/onboarding/attach_existing_git.js +65 -0
  106. package/dist/lib/onboarding/binding.js +91 -0
  107. package/dist/lib/onboarding/folder_state.js +99 -0
  108. package/dist/lib/onboarding/git_init.js +10 -0
  109. package/dist/lib/onboarding/handoff_apply.js +68 -0
  110. package/dist/lib/onboarding/init_fresh_folder.js +100 -0
  111. package/dist/lib/onboarding/manifest_writer.js +161 -0
  112. package/dist/lib/onboarding/web_first_attach.js +133 -0
  113. package/dist/lib/package_manager.js +23 -0
  114. package/dist/lib/review/client.js +66 -0
  115. package/dist/lib/schema_validator.js +24 -0
  116. package/dist/lib/secure_file.js +10 -0
  117. package/dist/lib/session_telemetry.js +263 -0
  118. package/dist/lib/sessions/client.js +48 -0
  119. package/dist/lib/sessions/outsideSessions.js +54 -0
  120. package/dist/lib/sse_framing.js +48 -0
  121. package/dist/lib/telemetry/config_renderer.js +175 -0
  122. package/dist/lib/telemetry/health_report.js +173 -0
  123. package/dist/lib/telemetry/provider_paths.js +38 -0
  124. package/dist/lib/telemetry/redaction_policy.js +90 -0
  125. package/dist/lib/telemetry/reload.js +84 -0
  126. package/dist/lib/workspace_context.js +121 -0
  127. package/dist/spec_schema.json +422 -0
  128. package/dist/wire/auth.js +1 -0
  129. package/dist/wire/sync-paths.js +25 -0
  130. package/dist/wire/telemetry.js +1 -0
  131. package/package.json +49 -0
@@ -0,0 +1,198 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { withReconciliationLock } from "../core/reconciliation_lock.js";
6
+ import { resolveRepository, readHeadCommit } from "../core/repository.js";
7
+ import { runGit } from "../core/git_runner.js";
8
+ import { getBindingById } from "../../local_store/bindings.js";
9
+ import { isCloudOriginCommit } from "../../local_store/commit_origins.js";
10
+ import { getApplication, upsertApplication, transitionApplicationState, } from "../../local_store/handoff_applications.js";
11
+ import { reportApplicationUndoResult } from "../../nustack_client.js";
12
+ import { buildCommit, materializeTarget, casBranchRef } from "./apply_commit.js";
13
+ function readUserIdentity(repo) {
14
+ const name = runGit(repo.repositoryRoot, ["config", "--get", "user.name"], { allowFailure: true }).stdout.trim();
15
+ const email = runGit(repo.repositoryRoot, ["config", "--get", "user.email"], { allowFailure: true }).stdout.trim();
16
+ return name.length > 0 && email.length > 0 ? { name, email } : null;
17
+ }
18
+ function treeOf(repo, ref) {
19
+ if (ref)
20
+ return runGit(repo.repositoryRoot, ["rev-parse", `${ref}^{tree}`]).stdout.trim();
21
+ const tmpIndex = path.join(tmpdir(), `nustack-empty-${randomUUID()}`);
22
+ try {
23
+ return runGit(repo.repositoryRoot, ["write-tree"], { indexFile: tmpIndex }).stdout.trim();
24
+ }
25
+ finally {
26
+ rmSync(tmpIndex, { force: true });
27
+ }
28
+ }
29
+ function firstParent(repo, commit) {
30
+ const result = runGit(repo.repositoryRoot, ["rev-parse", "--verify", "--quiet", `${commit}^`], { allowFailure: true });
31
+ const parent = result.stdout.trim();
32
+ return result.exitCode === 0 && parent.length > 0 ? parent : null;
33
+ }
34
+ function changedPaths(repo, parent, commit) {
35
+ const args = parent
36
+ ? ["diff-tree", "--no-commit-id", "--name-only", "-r", parent, commit]
37
+ : ["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", commit];
38
+ return runGit(repo.repositoryRoot, args)
39
+ .stdout.split("\n")
40
+ .map((line) => line.trim())
41
+ .filter((line) => line.length > 0);
42
+ }
43
+ function blobAt(repo, ref, targetPath) {
44
+ if (!ref)
45
+ return null;
46
+ const listed = runGit(repo.repositoryRoot, ["ls-tree", ref, "--", targetPath], { allowFailure: true });
47
+ const line = listed.stdout.split("\n").find((entry) => entry.trim().length > 0);
48
+ if (listed.exitCode !== 0 || !line)
49
+ return null;
50
+ const blobSha = (line.split("\t")[0] ?? "").split(/\s+/)[2] ?? "";
51
+ if (blobSha.length === 0)
52
+ return null;
53
+ return Buffer.from(runGit(repo.repositoryRoot, ["cat-file", "blob", blobSha]).stdout, "utf8");
54
+ }
55
+ function pathContentConflicts(repo, tmpIndex, targetPath) {
56
+ const unmerged = runGit(repo.repositoryRoot, ["ls-files", "--unmerged", "--", targetPath], { indexFile: tmpIndex }).stdout;
57
+ const stageSha = {};
58
+ for (const line of unmerged.split("\n")) {
59
+ if (line.trim().length === 0)
60
+ continue;
61
+ const meta = (line.split("\t")[0] ?? "").split(/\s+/);
62
+ const sha = meta[1] ?? "";
63
+ const stage = Number.parseInt(meta[2] ?? "0", 10);
64
+ if (sha.length > 0)
65
+ stageSha[stage] = sha;
66
+ }
67
+ if (!stageSha[2] || !stageSha[3])
68
+ return true;
69
+ const dir = mkdtempSync(path.join(tmpdir(), "nustack-merge-"));
70
+ try {
71
+ const write = (stage) => {
72
+ const file = path.join(dir, `stage${stage}`);
73
+ const content = stageSha[stage] ? runGit(repo.repositoryRoot, ["cat-file", "blob", stageSha[stage]]).stdout : "";
74
+ writeFileSync(file, content);
75
+ return file;
76
+ };
77
+ const ours = write(2);
78
+ const base = write(1);
79
+ const theirs = write(3);
80
+ const result = runGit(repo.repositoryRoot, ["merge-file", "-p", ours, base, theirs], { allowFailure: true });
81
+ return result.exitCode !== 0;
82
+ }
83
+ finally {
84
+ rmSync(dir, { recursive: true, force: true });
85
+ }
86
+ }
87
+ function preflightRevert(repo, inboundTree, headTree, parentTree) {
88
+ const tmpIndex = path.join(tmpdir(), `nustack-revert-${randomUUID()}`);
89
+ try {
90
+ const merged = runGit(repo.repositoryRoot, ["read-tree", "-i", "-m", inboundTree, headTree, parentTree], {
91
+ indexFile: tmpIndex,
92
+ allowFailure: true,
93
+ });
94
+ const unmerged = runGit(repo.repositoryRoot, ["ls-files", "--unmerged"], { indexFile: tmpIndex, allowFailure: true }).stdout;
95
+ const paths = new Set();
96
+ for (const line of unmerged.split("\n")) {
97
+ const file = line.split("\t")[1];
98
+ if (file && file.trim().length > 0)
99
+ paths.add(file.trim());
100
+ }
101
+ if (paths.size === 0) {
102
+ return merged.exitCode === 0 ? { clean: true } : { clean: false, conflictPaths: [] };
103
+ }
104
+ const conflictPaths = [...paths].filter((targetPath) => pathContentConflicts(repo, tmpIndex, targetPath));
105
+ return conflictPaths.length > 0 ? { clean: false, conflictPaths } : { clean: true };
106
+ }
107
+ finally {
108
+ rmSync(tmpIndex, { force: true });
109
+ }
110
+ }
111
+ export async function undoHandoff(store, deps, bindingId, handoffId) {
112
+ return withReconciliationLock(bindingId, async () => {
113
+ const binding = getBindingById(store, bindingId);
114
+ if (!binding)
115
+ return { ok: false, code: "not_found" };
116
+ const application = getApplication(store, handoffId, bindingId);
117
+ if (!application)
118
+ return { ok: false, code: "not_found" };
119
+ if (application.state !== "committed")
120
+ return { ok: false, code: "not_undoable" };
121
+ const inboundCommit = application.generatedCommitId;
122
+ if (!inboundCommit)
123
+ return { ok: false, code: "not_undoable" };
124
+ const repo = resolveRepository(binding.repositoryRoot);
125
+ const head = readHeadCommit(repo);
126
+ if (!head)
127
+ return { ok: false, code: "not_in_history" };
128
+ const ancestor = runGit(repo.repositoryRoot, ["merge-base", "--is-ancestor", inboundCommit, head], { allowFailure: true });
129
+ if (ancestor.exitCode !== 0)
130
+ return { ok: false, code: "not_in_history" };
131
+ if (!isCloudOriginCommit(store, bindingId, inboundCommit))
132
+ return { ok: false, code: "not_linked" };
133
+ const identity = readUserIdentity(repo);
134
+ if (!identity)
135
+ return { ok: false, code: "missing_identity" };
136
+ const parent = firstParent(repo, inboundCommit);
137
+ const inboundTree = treeOf(repo, inboundCommit);
138
+ const parentTree = treeOf(repo, parent);
139
+ const headTree = treeOf(repo, head);
140
+ transitionApplicationState(store, handoffId, bindingId, "undo_requested");
141
+ const preflight = preflightRevert(repo, inboundTree, headTree, parentTree);
142
+ if (!preflight.clean) {
143
+ transitionApplicationState(store, handoffId, bindingId, "revert_conflict");
144
+ upsertApplication(store, { handoffId, bindingId, notificationState: "unacknowledged" });
145
+ await reportUndo(deps, binding.id, handoffId, "revert_conflict", preflight.conflictPaths);
146
+ return { ok: false, code: "revert_conflict", conflictPaths: preflight.conflictPaths };
147
+ }
148
+ const paths = changedPaths(repo, parent, inboundCommit);
149
+ const inverseTargets = [];
150
+ const bytesByPath = new Map();
151
+ for (const targetPath of paths) {
152
+ const parentBytes = blobAt(repo, parent, targetPath);
153
+ if (parentBytes === null) {
154
+ inverseTargets.push({ path: targetPath, operation: "delete", targetSha256: null, byteSize: 0, base: { absent: true } });
155
+ }
156
+ else {
157
+ inverseTargets.push({ path: targetPath, operation: "upsert", targetSha256: null, byteSize: parentBytes.byteLength, base: { absent: true } });
158
+ bytesByPath.set(targetPath, parentBytes);
159
+ }
160
+ }
161
+ const message = `Revert NuStack sync commit ${inboundCommit.slice(0, 12)}\n\nThis reverts NuStack sync handoff ${handoffId}.\n`;
162
+ const built = buildCommit(repo, head, inverseTargets, bytesByPath, message, {
163
+ authorName: identity.name,
164
+ authorEmail: identity.email,
165
+ committerName: identity.name,
166
+ committerEmail: identity.email,
167
+ ...(deps.authorDate ? { authorDate: deps.authorDate } : {}),
168
+ ...(deps.committerDate ? { committerDate: deps.committerDate } : {}),
169
+ });
170
+ for (const target of inverseTargets) {
171
+ materializeTarget(repo, target, built.blobByPath.get(target.path) ?? null, bytesByPath.get(target.path) ?? null);
172
+ }
173
+ if (!casBranchRef(repo, binding.boundBranch, built.commitId, head)) {
174
+ return { ok: false, code: "cas_conflict" };
175
+ }
176
+ upsertApplication(store, { handoffId, bindingId, revertCommitId: built.commitId, notificationState: "unacknowledged" });
177
+ transitionApplicationState(store, handoffId, bindingId, "reverted");
178
+ const revertedPaths = inverseTargets.map((target) => target.path);
179
+ deps.emitter?.emit({ event: "undo_result", bindingId, handoffId, outcome: "reverted", paths: revertedPaths, revertCommitId: built.commitId });
180
+ await reportUndo(deps, binding.id, handoffId, "reverted", revertedPaths, built.commitId);
181
+ return { ok: true, revertCommitId: built.commitId, revertedPaths };
182
+ });
183
+ }
184
+ async function reportUndo(deps, bindingId, handoffId, outcome, affectedPaths, commitId) {
185
+ const reportUndoResult = deps.reportUndo ?? reportApplicationUndoResult;
186
+ try {
187
+ await reportUndoResult(deps.baseUrl, deps.accessToken, {
188
+ bindingId,
189
+ handoffId,
190
+ idempotencyKey: `undo:${bindingId}:${handoffId}`,
191
+ outcome,
192
+ affectedPaths,
193
+ commitId: commitId ?? null,
194
+ });
195
+ }
196
+ catch {
197
+ }
198
+ }
@@ -0,0 +1,107 @@
1
+ import { createHash } from "node:crypto";
2
+ import { runGit } from "../core/git_runner.js";
3
+ import { readRefCommit } from "../core/repository.js";
4
+ import { normalizeRepositoryPath, findCaseFoldCollisions, findUnicodeCollisions, } from "../core/path_normalizer.js";
5
+ import { matchPath } from "../whitelist/matcher.js";
6
+ import { validateOperationContent } from "../whitelist/validation.js";
7
+ const ALL_ZERO_SHA = /^0+$/;
8
+ function sha256Hex(bytes) {
9
+ return createHash("sha256").update(bytes).digest("hex");
10
+ }
11
+ function parseDiffTree(stdout) {
12
+ const parts = stdout.split("\0");
13
+ const records = [];
14
+ for (let i = 0; i + 1 < parts.length; i += 2) {
15
+ const meta = parts[i];
16
+ const rawPath = parts[i + 1];
17
+ if (!meta || meta.length === 0 || rawPath === undefined || rawPath.length === 0)
18
+ continue;
19
+ const fields = meta.replace(/^:/, "").split(" ");
20
+ if (fields.length < 5)
21
+ continue;
22
+ records.push({
23
+ srcMode: fields[0],
24
+ dstMode: fields[1],
25
+ srcSha: fields[2],
26
+ dstSha: fields[3],
27
+ status: fields[4],
28
+ rawPath,
29
+ });
30
+ }
31
+ return records;
32
+ }
33
+ function readBlob(repo, blobId) {
34
+ const size = Number.parseInt(runGit(repo.repositoryRoot, ["cat-file", "-s", blobId]).stdout.trim(), 10);
35
+ if (!Number.isFinite(size))
36
+ return null;
37
+ const { stdout } = runGit(repo.repositoryRoot, ["cat-file", "blob", blobId]);
38
+ const bytes = Buffer.from(stdout, "utf8");
39
+ if (bytes.byteLength !== size)
40
+ return null;
41
+ return { bytes, byteSize: size };
42
+ }
43
+ export function isCommitTreeReadable(repo, commitId) {
44
+ return runGit(repo.repositoryRoot, ["cat-file", "-e", `${commitId}^{tree}`], { allowFailure: true }).exitCode === 0;
45
+ }
46
+ export function extractCommitDelta(repo, commitId, whitelist, workspaceId, direction = "local_to_cloud") {
47
+ const parentCommitId = readRefCommit(repo, `${commitId}^1`);
48
+ return computeTreeDelta(repo, parentCommitId, commitId, whitelist, workspaceId, direction);
49
+ }
50
+ export function computeTreeDelta(repo, baseCommitId, targetCommitId, whitelist, workspaceId, direction = "local_to_cloud") {
51
+ const diffArgs = ["diff-tree", "-r", "-z", "--no-renames", "--no-commit-id"];
52
+ const args = baseCommitId === null ? [...diffArgs, "--root", targetCommitId] : [...diffArgs, baseCommitId, targetCommitId];
53
+ const { stdout } = runGit(repo.repositoryRoot, args);
54
+ const records = parseDiffTree(stdout);
55
+ const operations = [];
56
+ for (const record of records) {
57
+ const normalized = normalizeRepositoryPath(record.rawPath);
58
+ if (!normalized.ok) {
59
+ if (normalized.code === "protected_path")
60
+ continue;
61
+ return { ok: false, code: "invalid_path", path: record.rawPath };
62
+ }
63
+ const path = normalized.path;
64
+ const match = matchPath(whitelist, path, direction);
65
+ if (!match.allowed)
66
+ continue;
67
+ if (record.status === "D") {
68
+ operations.push({
69
+ path,
70
+ operation: "delete",
71
+ previousGitBlobId: ALL_ZERO_SHA.test(record.srcSha) ? undefined : record.srcSha,
72
+ });
73
+ continue;
74
+ }
75
+ const blob = readBlob(repo, record.dstSha);
76
+ if (!blob)
77
+ return { ok: false, code: "invalid_encoding", path };
78
+ const validation = validateOperationContent({
79
+ path,
80
+ mode: record.dstMode,
81
+ bytes: blob.bytes,
82
+ byteSize: blob.byteSize,
83
+ entry: match.entry,
84
+ workspaceId,
85
+ });
86
+ if (!validation.ok)
87
+ return { ok: false, code: validation.code, path };
88
+ operations.push({
89
+ path,
90
+ operation: "upsert",
91
+ gitBlobId: record.dstSha,
92
+ previousGitBlobId: ALL_ZERO_SHA.test(record.srcSha) ? undefined : record.srcSha,
93
+ contentSha256: sha256Hex(blob.bytes),
94
+ byteSize: blob.byteSize,
95
+ bytes: blob.bytes,
96
+ mode: record.dstMode,
97
+ });
98
+ }
99
+ const collision = firstCollision(operations.map((op) => op.path));
100
+ if (collision)
101
+ return { ok: false, code: "path_collision", path: collision };
102
+ return { ok: true, delta: { commitId: targetCommitId, parentCommitId: baseCommitId, operations } };
103
+ }
104
+ function firstCollision(paths) {
105
+ const groups = [...findCaseFoldCollisions(paths), ...findUnicodeCollisions(paths)];
106
+ return groups[0]?.[0] ?? null;
107
+ }
@@ -0,0 +1,112 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { updateBindingCursor } from "../../local_store/bindings.js";
3
+ import { insertHistoryDecision } from "../../local_store/history_decisions.js";
4
+ import { readHeadCommit } from "../core/repository.js";
5
+ import { resolveCommitRange } from "../core/commit_range.js";
6
+ import { withReconciliationLock } from "../core/reconciliation_lock.js";
7
+ import { loadPinnedWhitelist } from "../whitelist/store.js";
8
+ import { computeTreeDelta, isCommitTreeReadable } from "./extraction.js";
9
+ import { pushOperations } from "./queue.js";
10
+ function mintHistoryDecisionId() {
11
+ const timePrefix = Date.now().toString(16).padStart(12, "0");
12
+ return `hdx_${timePrefix}${randomBytes(6).toString("hex")}`;
13
+ }
14
+ export function describeHistoryDecision(store, binding, repo) {
15
+ void store;
16
+ const headCommitId = readHeadCommit(repo);
17
+ const range = resolveCommitRange(repo, {
18
+ boundBranch: binding.boundBranch,
19
+ lastProcessedCommitId: binding.lastProcessedCommitId,
20
+ });
21
+ const oldBaselineCommitId = binding.lastProcessedCommitId;
22
+ return {
23
+ bindingId: binding.id,
24
+ pending: range.kind === "history_decision_required",
25
+ oldBaselineCommitId,
26
+ headCommitId,
27
+ baselineReadable: oldBaselineCommitId !== null && isCommitTreeReadable(repo, oldBaselineCommitId),
28
+ };
29
+ }
30
+ export async function publishCurrentState(store, binding, repo, deps) {
31
+ return withReconciliationLock(binding.id, async () => {
32
+ const head = readHeadCommit(repo);
33
+ if (head === null)
34
+ return { ok: false, code: "NO_HEAD" };
35
+ let whitelist;
36
+ try {
37
+ whitelist = await loadPinnedWhitelist(store, binding, deps.client);
38
+ }
39
+ catch {
40
+ return { ok: false, code: "WHITELIST_UNAVAILABLE" };
41
+ }
42
+ const oldBaseline = binding.lastProcessedCommitId;
43
+ const baselineReadable = oldBaseline !== null && isCommitTreeReadable(repo, oldBaseline);
44
+ const base = baselineReadable ? oldBaseline : null;
45
+ const mode = baselineReadable ? "exact_delta" : "full_tree";
46
+ const ctx = {
47
+ store,
48
+ binding,
49
+ repo,
50
+ client: deps.client,
51
+ whitelistRef: { current: whitelist },
52
+ workspaceId: binding.workspaceId,
53
+ now: deps.now ?? Date.now,
54
+ };
55
+ for (let staleDepth = 0;; staleDepth++) {
56
+ const delta = computeTreeDelta(repo, base, head, ctx.whitelistRef.current, binding.workspaceId);
57
+ if (!delta.ok)
58
+ return { ok: false, code: delta.code, detail: delta.path };
59
+ const result = await pushOperations(ctx, head, base, delta.delta.operations);
60
+ if (result.kind === "stale") {
61
+ if (staleDepth >= 2)
62
+ return { ok: false, code: "SYNC_WHITELIST_STALE" };
63
+ try {
64
+ ctx.whitelistRef.current = await loadPinnedWhitelist(store, binding, deps.client);
65
+ }
66
+ catch {
67
+ return { ok: false, code: "WHITELIST_UNAVAILABLE" };
68
+ }
69
+ continue;
70
+ }
71
+ if (result.kind === "retry_scheduled")
72
+ return { ok: false, code: result.code };
73
+ if (result.kind === "blocked")
74
+ return { ok: false, code: result.code, detail: result.path };
75
+ if (result.kind === "not_due")
76
+ return { ok: false, code: "BACKOFF" };
77
+ const handoffId = result.kind === "accepted" ? result.handoffId : undefined;
78
+ updateBindingCursor(store, binding.id, { lastProcessedCommitId: head });
79
+ const decisionId = recordDecision(store, binding.id, "publish_current_state", oldBaseline, head, handoffId ?? null);
80
+ await reportDecisionHealth(deps, binding.id, head, "publish_current_state", oldBaseline, handoffId);
81
+ emitResolved(deps, binding.id, "publish_current_state", head, handoffId);
82
+ return { ok: true, newBaselineCommitId: head, mode, handoffId, noOp: result.kind === "no_op", decisionId };
83
+ }
84
+ });
85
+ }
86
+ export async function adoptWithoutPublishing(store, binding, repo, deps) {
87
+ return withReconciliationLock(binding.id, async () => {
88
+ const head = readHeadCommit(repo);
89
+ if (head === null)
90
+ return { ok: false, code: "NO_HEAD" };
91
+ const oldBaseline = binding.lastProcessedCommitId;
92
+ updateBindingCursor(store, binding.id, { lastProcessedCommitId: head });
93
+ const decisionId = recordDecision(store, binding.id, "adopt_without_publishing", oldBaseline, head, null);
94
+ await reportDecisionHealth(deps, binding.id, head, "adopt_without_publishing", oldBaseline, undefined);
95
+ emitResolved(deps, binding.id, "adopt_without_publishing", head, undefined);
96
+ return { ok: true, newBaselineCommitId: head, decisionId };
97
+ });
98
+ }
99
+ function recordDecision(store, bindingId, decision, oldBaselineCommitId, newBaselineCommitId, handoffId) {
100
+ const id = mintHistoryDecisionId();
101
+ insertHistoryDecision(store, { id, bindingId, decision, oldBaselineCommitId, newBaselineCommitId, handoffId });
102
+ return id;
103
+ }
104
+ async function reportDecisionHealth(deps, bindingId, newBaselineCommitId, decision, oldBaselineCommitId, handoffId) {
105
+ await deps.client.reportHealth(bindingId, {
106
+ lastProcessedCommitId: newBaselineCommitId,
107
+ lastHistoryDecision: { decision, oldBaselineCommitId, newBaselineCommitId, ...(handoffId ? { handoffId } : {}) },
108
+ });
109
+ }
110
+ function emitResolved(deps, bindingId, decision, newBaselineCommitId, handoffId) {
111
+ deps.emit?.({ event: "history_decision_resolved", bindingId, decision, newBaselineCommitId, ...(handoffId ? { handoffId } : {}) });
112
+ }
@@ -0,0 +1,26 @@
1
+ import { ensureAccessToken } from "../../auth_grant.js";
2
+ import { createUploadClient } from "./upload_client.js";
3
+ import { runOutboundPublisher } from "./publisher.js";
4
+ export function deviceTokenProvider(baseUrl) {
5
+ let cached = null;
6
+ return async (forceRefresh) => {
7
+ if (cached && !forceRefresh)
8
+ return cached;
9
+ const result = await ensureAccessToken(baseUrl);
10
+ cached = result?.accessToken ?? null;
11
+ return cached;
12
+ };
13
+ }
14
+ export function registerOutbound(engine, options) {
15
+ const getAccessToken = options.getAccessToken ?? deviceTokenProvider(options.baseUrl);
16
+ const client = createUploadClient({ baseUrl: options.baseUrl, getAccessToken, fetchImpl: options.fetchImpl });
17
+ engine.registerOutboundPublisher((ctx) => runOutboundPublisher(ctx, { client, now: options.now }));
18
+ return client;
19
+ }
20
+ export { runOutboundPublisher } from "./publisher.js";
21
+ export { submitHead, pushOperations, backoffMs, enqueueCommit } from "./queue.js";
22
+ export { describeHistoryDecision, publishCurrentState, adoptWithoutPublishing, } from "./history_decision.js";
23
+ export { createUploadClient, classifyFailure, UploadRequestError } from "./upload_client.js";
24
+ export { extractCommitDelta } from "./extraction.js";
25
+ export { buildGitHandoffManifest, deriveIdempotencyKey } from "./manifest_builder.js";
26
+ export { OUTBOUND_STATES } from "./state.js";
@@ -0,0 +1,58 @@
1
+ import { createHash } from "node:crypto";
2
+ function sha256Hex(input) {
3
+ return createHash("sha256").update(input, "utf8").digest("hex");
4
+ }
5
+ function sortKeys(value) {
6
+ if (Array.isArray(value))
7
+ return value.map(sortKeys);
8
+ if (value && typeof value === "object") {
9
+ const source = value;
10
+ const out = {};
11
+ for (const key of Object.keys(source).sort())
12
+ out[key] = sortKeys(source[key]);
13
+ return out;
14
+ }
15
+ return value;
16
+ }
17
+ function toManifestOperation(op) {
18
+ if (op.operation === "delete") {
19
+ return { path: op.path, operation: "delete", ...(op.previousGitBlobId ? { previousGitBlobId: op.previousGitBlobId } : {}) };
20
+ }
21
+ return {
22
+ path: op.path,
23
+ operation: "upsert",
24
+ gitBlobId: op.gitBlobId,
25
+ contentSha256: op.contentSha256,
26
+ byteSize: op.byteSize,
27
+ ...(op.previousGitBlobId ? { previousGitBlobId: op.previousGitBlobId } : {}),
28
+ };
29
+ }
30
+ function sortOperations(operations) {
31
+ return [...operations].sort((a, b) => {
32
+ if (a.path !== b.path)
33
+ return a.path < b.path ? -1 : 1;
34
+ if (a.operation !== b.operation)
35
+ return a.operation < b.operation ? -1 : 1;
36
+ return 0;
37
+ });
38
+ }
39
+ export function deriveIdempotencyKey(bindingId, commitId, whitelistManifestSha256, manifestSha256) {
40
+ return sha256Hex(`git-handoff:v1:${bindingId}:${commitId}:${whitelistManifestSha256}:${manifestSha256}`);
41
+ }
42
+ export function buildGitHandoffManifest(input) {
43
+ const operations = sortOperations(input.operations.map(toManifestOperation));
44
+ const canonicalCore = {
45
+ schemaVersion: 1,
46
+ commitId: input.commitId,
47
+ parentCommitId: input.parentCommitId,
48
+ branch: input.binding.boundBranch,
49
+ whitelistManifestId: input.whitelist.manifestId,
50
+ whitelistManifestSha256: input.whitelist.manifestSha256,
51
+ operations,
52
+ };
53
+ const canonicalManifestBytes = JSON.stringify(sortKeys(canonicalCore));
54
+ const manifestSha256 = sha256Hex(canonicalManifestBytes);
55
+ const manifest = { ...canonicalCore, manifestSha256 };
56
+ const idempotencyKey = deriveIdempotencyKey(input.binding.id, input.commitId, input.whitelist.manifestSha256, manifestSha256);
57
+ return { manifest, canonicalManifestBytes, manifestSha256, idempotencyKey };
58
+ }
@@ -0,0 +1,51 @@
1
+ import { updateBindingCursor } from "../../local_store/bindings.js";
2
+ import { isCloudOriginCommit } from "../../local_store/commit_origins.js";
3
+ import { loadPinnedWhitelist } from "../whitelist/store.js";
4
+ import { submitHead } from "./queue.js";
5
+ export async function runOutboundPublisher(ctx, runtime) {
6
+ const { store, repo, binding, commitIds, emit } = ctx;
7
+ const now = runtime.now ?? Date.now;
8
+ let whitelist;
9
+ try {
10
+ whitelist = await loadPinnedWhitelist(store, binding, runtime.client);
11
+ }
12
+ catch {
13
+ emit({ event: "error", bindingId: binding.id, code: "whitelist_unavailable", message: "the sync whitelist could not be loaded" });
14
+ return;
15
+ }
16
+ const submitCtx = {
17
+ store,
18
+ binding,
19
+ repo,
20
+ client: runtime.client,
21
+ whitelistRef: { current: whitelist },
22
+ workspaceId: binding.workspaceId,
23
+ now,
24
+ };
25
+ for (const commitId of commitIds) {
26
+ if (isCloudOriginCommit(store, binding.id, commitId)) {
27
+ updateBindingCursor(store, binding.id, { lastProcessedCommitId: commitId });
28
+ continue;
29
+ }
30
+ emit({ event: "upload_started", bindingId: binding.id, commitId });
31
+ const outcome = await submitHead(submitCtx, commitId);
32
+ switch (outcome.kind) {
33
+ case "accepted":
34
+ updateBindingCursor(store, binding.id, { lastProcessedCommitId: commitId });
35
+ emit({ event: "upload_result", bindingId: binding.id, commitId, outcome: "accepted", handoffId: outcome.handoffId, workspaceSequence: outcome.workspaceSequence });
36
+ break;
37
+ case "no_op":
38
+ updateBindingCursor(store, binding.id, { lastProcessedCommitId: commitId });
39
+ emit({ event: "upload_result", bindingId: binding.id, commitId, outcome: "no_op" });
40
+ break;
41
+ case "retry_scheduled":
42
+ emit({ event: "upload_result", bindingId: binding.id, commitId, outcome: "retry_scheduled", errorCode: outcome.code });
43
+ return;
44
+ case "not_due":
45
+ return;
46
+ case "blocked":
47
+ emit({ event: "upload_result", bindingId: binding.id, commitId, outcome: "blocked", errorCode: outcome.code });
48
+ return;
49
+ }
50
+ }
51
+ }