@gajae-code/coding-agent 0.12.7 → 0.12.8
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/CHANGELOG.md +19 -0
- package/dist/types/commands/notify.d.ts +4 -0
- package/dist/types/config/file-lock.d.ts +3 -0
- package/dist/types/config/model-discovery-manager.d.ts +6 -1
- package/dist/types/config/model-registry.d.ts +2 -0
- package/dist/types/config/resolve-config-value.d.ts +2 -1
- package/dist/types/config/settings-schema.d.ts +17 -1
- package/dist/types/main.d.ts +1 -0
- package/dist/types/modes/components/hook-selector.d.ts +20 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +53 -4
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +4 -2
- package/dist/types/sdk/broker/broker.d.ts +5 -1
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
- package/dist/types/sdk/bus/config.d.ts +75 -113
- package/dist/types/sdk/bus/discord-live-provider.d.ts +8 -2
- package/dist/types/sdk/bus/discord-provider.d.ts +19 -0
- package/dist/types/sdk/bus/index.d.ts +1 -1
- package/dist/types/sdk/bus/notification-orchestration.d.ts +127 -18
- package/dist/types/sdk/bus/notification-service.d.ts +31 -4
- package/dist/types/sdk/bus/session-control.d.ts +8 -3
- package/dist/types/sdk/bus/slack-live-provider.d.ts +9 -2
- package/dist/types/sdk/bus/slack-provider.d.ts +22 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
- package/dist/types/sdk/client/discovery.d.ts +2 -1
- package/dist/types/sdk/host/query/handlers.d.ts +2 -0
- package/dist/types/sdk/index.d.ts +1 -0
- package/dist/types/sdk/providers.d.ts +22 -0
- package/dist/types/sdk/session-directory.d.ts +1 -1
- package/dist/types/session/internal/managed-session-scope.d.ts +17 -1
- package/dist/types/session/internal/managed-session-storage.d.ts +11 -1
- package/dist/types/session/internal/native-publish-outcome.d.ts +1 -0
- package/dist/types/session/internal/session-open-errors.d.ts +5 -0
- package/dist/types/session/session-manager.d.ts +3 -1
- package/dist/types/session/session-storage.d.ts +2 -0
- package/package.json +10 -8
- package/scripts/build-sdk-package-smoke.ts +51 -0
- package/scripts/capture-notifications-settings-showcase.ts +1 -1
- package/src/cli/notify-cli.ts +204 -94
- package/src/commands/notify.ts +19 -8
- package/src/config/file-lock.ts +6 -0
- package/src/config/model-discovery-manager.ts +50 -8
- package/src/config/model-registry.ts +862 -75
- package/src/config/resolve-config-value.ts +10 -8
- package/src/config/settings-schema.ts +25 -15
- package/src/config/settings.ts +15 -15
- package/src/gjc-runtime/session-state-sidecar.ts +12 -2
- package/src/internal-urls/docs-index.generated.ts +6 -6
- package/src/main.ts +43 -7
- package/src/modes/DESIGN.md +11 -11
- package/src/modes/components/hook-selector.ts +155 -44
- package/src/modes/components/model-selector.ts +242 -49
- package/src/modes/components/notifications-settings-editor.ts +631 -85
- package/src/modes/controllers/extension-ui-controller.ts +114 -15
- package/src/modes/controllers/selector-controller.ts +464 -50
- package/src/sdk/broker/broker.ts +54 -10
- package/src/sdk/broker/lifecycle-ledger.ts +34 -1
- package/src/sdk/broker/lifecycle.ts +47 -50
- package/src/sdk/bus/chat-daemon-cli.ts +12 -5
- package/src/sdk/bus/chat-daemon-control.ts +35 -6
- package/src/sdk/bus/chat-daemon-runtime.ts +16 -2
- package/src/sdk/bus/config.ts +534 -286
- package/src/sdk/bus/discord-live-provider.ts +57 -2
- package/src/sdk/bus/discord-provider.ts +21 -0
- package/src/sdk/bus/index.ts +97 -35
- package/src/sdk/bus/notification-orchestration.ts +371 -68
- package/src/sdk/bus/notification-service.ts +431 -130
- package/src/sdk/bus/session-control.ts +119 -44
- package/src/sdk/bus/slack-live-provider.ts +78 -2
- package/src/sdk/bus/slack-provider.ts +24 -0
- package/src/sdk/bus/telegram-daemon-cli.ts +9 -2
- package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
- package/src/sdk/bus/telegram-daemon-control.ts +3 -3
- package/src/sdk/bus/telegram-daemon.ts +23 -15
- package/src/sdk/client/discovery.ts +10 -4
- package/src/sdk/host/query/handlers.ts +20 -1
- package/src/sdk/index.ts +1 -0
- package/src/sdk/protocol/operation-inventory.generated.json +18 -0
- package/src/sdk/protocol/operation-registry.ts +5 -2
- package/src/sdk/providers.ts +51 -0
- package/src/sdk/session-directory.ts +1 -0
- package/src/sdk/session.ts +10 -7
- package/src/session/agent-session.ts +44 -24
- package/src/session/internal/managed-session-scope.ts +981 -450
- package/src/session/internal/managed-session-storage.ts +382 -45
- package/src/session/internal/native-publish-outcome.ts +3 -1
- package/src/session/internal/session-open-errors.ts +11 -0
- package/src/session/session-manager.ts +27 -3
- package/src/session/session-storage.ts +72 -5
- package/src/slash-commands/builtin-registry.ts +82 -4
- package/src/task/index.ts +9 -3
- package/src/tools/ask.ts +1 -1
- package/src/tools/bash.ts +1 -1
- package/src/tools/telegram-send.ts +4 -2
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
applyOwnerOnlyFdSecurity,
|
|
7
7
|
applyOwnerOnlyPathSecurity,
|
|
8
8
|
exactRemoveDirectoryTree,
|
|
9
|
+
exactReplacePath,
|
|
10
|
+
exactRestore,
|
|
9
11
|
exactUnlink,
|
|
10
12
|
type NativeDirectoryTreeSnapshot,
|
|
11
13
|
type NativeOwnerOnlySecurityResult,
|
|
@@ -34,7 +36,6 @@ const LOCK_LEASE_MS = 60_000;
|
|
|
34
36
|
const LOCK_HEARTBEAT_MS = 10_000;
|
|
35
37
|
const LOCK_WAIT_MS = 5_000;
|
|
36
38
|
|
|
37
|
-
const LOCK_STALE_RECHECK_MS = 100;
|
|
38
39
|
export class ManagedPublishError extends Error {
|
|
39
40
|
readonly classification:
|
|
40
41
|
| "destination_conflict"
|
|
@@ -138,7 +139,38 @@ export interface ManagedStorageLock {
|
|
|
138
139
|
|
|
139
140
|
export interface ManagedFileSnapshot {
|
|
140
141
|
bytes: Buffer;
|
|
141
|
-
identity: {
|
|
142
|
+
identity: {
|
|
143
|
+
dev: bigint;
|
|
144
|
+
ino: bigint;
|
|
145
|
+
nlink: bigint;
|
|
146
|
+
size: number;
|
|
147
|
+
mtimeNs: bigint;
|
|
148
|
+
ctimeNs: bigint;
|
|
149
|
+
sha256: string;
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
type ReplacementCleanupReceipt = {
|
|
154
|
+
version: 1;
|
|
155
|
+
predecessor: string;
|
|
156
|
+
successor: string;
|
|
157
|
+
identity: {
|
|
158
|
+
dev: string;
|
|
159
|
+
ino: string;
|
|
160
|
+
nlink: string;
|
|
161
|
+
size: string;
|
|
162
|
+
mtimeNs: string;
|
|
163
|
+
ctimeNs: string;
|
|
164
|
+
sha256: string;
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const U64_MAX = 18_446_744_073_709_551_615n;
|
|
169
|
+
|
|
170
|
+
function parseCanonicalU64(value: unknown): bigint | undefined {
|
|
171
|
+
if (typeof value !== "string" || !/^(0|[1-9][0-9]*)$/.test(value)) return undefined;
|
|
172
|
+
const parsed = BigInt(value);
|
|
173
|
+
return parsed <= U64_MAX ? parsed : undefined;
|
|
142
174
|
}
|
|
143
175
|
|
|
144
176
|
const ACL_FAILURE_CODES = new Set(["acl_denied", "acl_io_error", "acl_present", "acl_malformed", "acl_unknown"]);
|
|
@@ -264,8 +296,19 @@ type LockRecord = {
|
|
|
264
296
|
createdAt: number;
|
|
265
297
|
heartbeatAt: number;
|
|
266
298
|
leaseExpiresAt: number;
|
|
299
|
+
released?: boolean;
|
|
267
300
|
};
|
|
268
301
|
|
|
302
|
+
export interface ManagedLockRetirementTestEvent {
|
|
303
|
+
readonly path: string;
|
|
304
|
+
readonly attemptId: string;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Test-only seam immediately before exact retirement of one observed lock identity. */
|
|
308
|
+
export const ManagedLockTestHooks: {
|
|
309
|
+
beforeObservedRetirement?: (event: ManagedLockRetirementTestEvent) => void;
|
|
310
|
+
} = {};
|
|
311
|
+
|
|
269
312
|
/** Captured configured-root authority for managed paths only. */
|
|
270
313
|
export interface ManagedDirectoryRoot {
|
|
271
314
|
readonly canonicalPath: string;
|
|
@@ -475,6 +518,7 @@ export class ManagedSessionDescendantStore {
|
|
|
475
518
|
readonly #authority: RecoveryFsRoot | undefined;
|
|
476
519
|
#ownsAuthority = false;
|
|
477
520
|
#closed = false;
|
|
521
|
+
#reconcilingReplacementCleanup = false;
|
|
478
522
|
readonly #authorityBaseDir: string;
|
|
479
523
|
/** Logical profile root inherited by nested managed session destinations. */
|
|
480
524
|
readonly #profileAgentDir: string;
|
|
@@ -650,7 +694,86 @@ export class ManagedSessionDescendantStore {
|
|
|
650
694
|
}
|
|
651
695
|
}
|
|
652
696
|
|
|
697
|
+
#reconcileReplacementCleanupReceipts(): void {
|
|
698
|
+
if (this.#reconcilingReplacementCleanup) return;
|
|
699
|
+
this.#reconcilingReplacementCleanup = true;
|
|
700
|
+
try {
|
|
701
|
+
for (const name of fs.readdirSync(this.#baseDir)) {
|
|
702
|
+
const match = /^\.gjc-replace-cleanup-([0-9a-f]+)-([0-9a-f]+)\.json$/.exec(name);
|
|
703
|
+
if (!match) continue;
|
|
704
|
+
const receiptPath = path.join(this.#baseDir, name);
|
|
705
|
+
const parsed = JSON.parse(
|
|
706
|
+
captureManagedFileNoFollow(receiptPath).bytes.toString("utf8"),
|
|
707
|
+
) as Partial<ReplacementCleanupReceipt>;
|
|
708
|
+
const identity = parsed.identity;
|
|
709
|
+
const dev = parseCanonicalU64(identity?.dev);
|
|
710
|
+
const ino = parseCanonicalU64(identity?.ino);
|
|
711
|
+
const nlink = parseCanonicalU64(identity?.nlink);
|
|
712
|
+
const size = parseCanonicalU64(identity?.size);
|
|
713
|
+
const mtimeNs = parseCanonicalU64(identity?.mtimeNs);
|
|
714
|
+
const ctimeNs = parseCanonicalU64(identity?.ctimeNs);
|
|
715
|
+
const expectedPredecessor =
|
|
716
|
+
dev !== undefined && ino !== undefined
|
|
717
|
+
? path.join(this.#baseDir, `.gjc-exact-replace-destination-${dev.toString(16)}-${ino.toString(16)}`)
|
|
718
|
+
: undefined;
|
|
719
|
+
if (
|
|
720
|
+
parsed.version !== 1 ||
|
|
721
|
+
dev === undefined ||
|
|
722
|
+
ino === undefined ||
|
|
723
|
+
nlink === undefined ||
|
|
724
|
+
size === undefined ||
|
|
725
|
+
mtimeNs === undefined ||
|
|
726
|
+
ctimeNs === undefined ||
|
|
727
|
+
typeof identity?.sha256 !== "string" ||
|
|
728
|
+
!match[1] ||
|
|
729
|
+
!match[2] ||
|
|
730
|
+
BigInt(`0x${match[1]}`) !== dev ||
|
|
731
|
+
BigInt(`0x${match[2]}`) !== ino ||
|
|
732
|
+
typeof parsed.predecessor !== "string" ||
|
|
733
|
+
path.resolve(parsed.predecessor) !== expectedPredecessor ||
|
|
734
|
+
typeof parsed.successor !== "string" ||
|
|
735
|
+
path.dirname(path.resolve(parsed.successor)) !== this.#baseDir
|
|
736
|
+
)
|
|
737
|
+
throw new Error("managed_replace_cleanup_receipt_invalid");
|
|
738
|
+
const retired = exactUnlink(expectedPredecessor, {
|
|
739
|
+
dev,
|
|
740
|
+
ino,
|
|
741
|
+
nlink,
|
|
742
|
+
parentDev: this.#subtreeRoot.dev,
|
|
743
|
+
parentIno: this.#subtreeRoot.ino,
|
|
744
|
+
size,
|
|
745
|
+
mtimeNs,
|
|
746
|
+
sha256: identity.sha256,
|
|
747
|
+
quarantineName: `.gjc-replace-retry-${dev.toString(16)}-${ino.toString(16)}`,
|
|
748
|
+
});
|
|
749
|
+
if (!retired.ok) throw new Error(`managed_replace_cleanup_pending:${retired.code ?? "unknown"}`);
|
|
750
|
+
const receipt = captureManagedFileNoFollow(receiptPath);
|
|
751
|
+
const removed = exactUnlink(receiptPath, {
|
|
752
|
+
dev: receipt.identity.dev,
|
|
753
|
+
ino: receipt.identity.ino,
|
|
754
|
+
nlink: receipt.identity.nlink,
|
|
755
|
+
parentDev: this.#subtreeRoot.dev,
|
|
756
|
+
parentIno: this.#subtreeRoot.ino,
|
|
757
|
+
size: BigInt(receipt.identity.size),
|
|
758
|
+
mtimeNs: receipt.identity.mtimeNs,
|
|
759
|
+
sha256: receipt.identity.sha256,
|
|
760
|
+
quarantineName: `.gjc-receipt-remove-${receipt.identity.dev.toString(16)}-${receipt.identity.ino.toString(16)}`,
|
|
761
|
+
});
|
|
762
|
+
if (!removed.ok) throw new Error(`managed_replace_receipt_cleanup_pending:${removed.code ?? "unknown"}`);
|
|
763
|
+
}
|
|
764
|
+
} finally {
|
|
765
|
+
this.#reconcilingReplacementCleanup = false;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
#beforeMutation(): void {
|
|
770
|
+
this.#assertBound();
|
|
771
|
+
this.#reconcileReplacementCleanupReceipts();
|
|
772
|
+
this.#assertBound();
|
|
773
|
+
}
|
|
774
|
+
|
|
653
775
|
ensureDirectory(relativePath = ""): ManagedDirectoryRoot {
|
|
776
|
+
this.#beforeMutation();
|
|
654
777
|
this.#assertBound();
|
|
655
778
|
if (this.#authority) {
|
|
656
779
|
const relative = this.#relative(this.#resolve(relativePath));
|
|
@@ -670,6 +793,7 @@ export class ManagedSessionDescendantStore {
|
|
|
670
793
|
}
|
|
671
794
|
|
|
672
795
|
async publishNoReplace(relativePath: string, bytes: Uint8Array): Promise<void> {
|
|
796
|
+
this.#beforeMutation();
|
|
673
797
|
const resolved = this.#resolve(relativePath);
|
|
674
798
|
if (this.#authority) {
|
|
675
799
|
this.#assertBound();
|
|
@@ -681,6 +805,7 @@ export class ManagedSessionDescendantStore {
|
|
|
681
805
|
}
|
|
682
806
|
|
|
683
807
|
publishNoReplaceSync(relativePath: string, bytes: Uint8Array): void {
|
|
808
|
+
this.#beforeMutation();
|
|
684
809
|
const resolved = this.#resolve(relativePath);
|
|
685
810
|
if (!this.#authority) {
|
|
686
811
|
publishManagedFileNoReplaceSync(resolved, bytes, this.#root, this.#policy);
|
|
@@ -693,10 +818,17 @@ export class ManagedSessionDescendantStore {
|
|
|
693
818
|
}
|
|
694
819
|
|
|
695
820
|
async replace(relativePath: string, bytes: Uint8Array): Promise<void> {
|
|
821
|
+
this.#beforeMutation();
|
|
696
822
|
this.#assertBound();
|
|
697
823
|
const resolved = this.#resolve(relativePath);
|
|
698
824
|
if (!this.#authority) {
|
|
699
|
-
|
|
825
|
+
try {
|
|
826
|
+
const expected = captureManagedFileNoFollow(resolved);
|
|
827
|
+
replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy, undefined, expected.identity);
|
|
828
|
+
} catch (error) {
|
|
829
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
830
|
+
await publishManagedFileNoReplace(resolved, bytes, undefined, this.#subtreeRoot, this.#policy);
|
|
831
|
+
}
|
|
700
832
|
this.#assertBound();
|
|
701
833
|
return;
|
|
702
834
|
}
|
|
@@ -705,11 +837,56 @@ export class ManagedSessionDescendantStore {
|
|
|
705
837
|
this.#assertBound();
|
|
706
838
|
}
|
|
707
839
|
|
|
840
|
+
replaceExpected(relativePath: string, bytes: Uint8Array, expected: ManagedFileSnapshot): void {
|
|
841
|
+
this.#beforeMutation();
|
|
842
|
+
this.#assertBound();
|
|
843
|
+
const resolved = this.#resolve(relativePath);
|
|
844
|
+
if (!this.#authority) {
|
|
845
|
+
const current = captureManagedFileNoFollow(resolved);
|
|
846
|
+
if (!sameIdentity(current.identity, expected.identity) || current.identity.sha256 !== expected.identity.sha256)
|
|
847
|
+
throw new Error("managed_replace_identity_mismatch");
|
|
848
|
+
replaceManagedFileSync(
|
|
849
|
+
resolved,
|
|
850
|
+
bytes,
|
|
851
|
+
this.#subtreeRoot,
|
|
852
|
+
this.#policy,
|
|
853
|
+
() => {
|
|
854
|
+
const currentAtCommit = captureManagedFileNoFollow(resolved);
|
|
855
|
+
if (
|
|
856
|
+
!sameIdentity(currentAtCommit.identity, expected.identity) ||
|
|
857
|
+
currentAtCommit.identity.sha256 !== expected.identity.sha256
|
|
858
|
+
)
|
|
859
|
+
throw new Error("managed_replace_identity_mismatch");
|
|
860
|
+
},
|
|
861
|
+
expected.identity,
|
|
862
|
+
);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
const replaced = (this.#authority as RecoveryFsRoot & RetainedManagedReplacer).replaceManaged(
|
|
866
|
+
this.#relative(resolved),
|
|
867
|
+
bytes,
|
|
868
|
+
expected.identity.dev.toString(),
|
|
869
|
+
expected.identity.ino.toString(),
|
|
870
|
+
String(expected.identity.size),
|
|
871
|
+
expected.identity.mtimeNs.toString(),
|
|
872
|
+
expected.identity.ctimeNs.toString(),
|
|
873
|
+
expected.identity.sha256,
|
|
874
|
+
);
|
|
875
|
+
if (!replaced.ok) throw new Error(replaced.code ?? "managed_replace_failed");
|
|
876
|
+
this.#assertBound();
|
|
877
|
+
}
|
|
708
878
|
replaceSync(relativePath: string, bytes: Uint8Array): void {
|
|
879
|
+
this.#beforeMutation();
|
|
709
880
|
this.#assertBound();
|
|
710
881
|
const resolved = this.#resolve(relativePath);
|
|
711
882
|
if (!this.#authority) {
|
|
712
|
-
|
|
883
|
+
try {
|
|
884
|
+
const expected = captureManagedFileNoFollow(resolved);
|
|
885
|
+
replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy, undefined, expected.identity);
|
|
886
|
+
} catch (error) {
|
|
887
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
888
|
+
publishManagedFileNoReplaceSync(resolved, bytes, this.#subtreeRoot, this.#policy);
|
|
889
|
+
}
|
|
713
890
|
this.#assertBound();
|
|
714
891
|
return;
|
|
715
892
|
}
|
|
@@ -718,6 +895,7 @@ export class ManagedSessionDescendantStore {
|
|
|
718
895
|
}
|
|
719
896
|
|
|
720
897
|
appendSync(relativePath: string, bytes: Uint8Array): void {
|
|
898
|
+
this.#beforeMutation();
|
|
721
899
|
this.#assertBound();
|
|
722
900
|
const resolved = this.#resolve(relativePath);
|
|
723
901
|
let existing = this.readExpected(relativePath);
|
|
@@ -915,6 +1093,7 @@ export class ManagedSessionDescendantStore {
|
|
|
915
1093
|
identity: {
|
|
916
1094
|
dev: BigInt(read.identity.dev),
|
|
917
1095
|
ino: BigInt(read.identity.ino),
|
|
1096
|
+
nlink: BigInt(read.identity.nlink),
|
|
918
1097
|
size: Number(read.identity.size),
|
|
919
1098
|
mtimeNs: BigInt(read.identity.mtimeNs),
|
|
920
1099
|
ctimeNs: BigInt(read.identity.ctimeNs),
|
|
@@ -925,6 +1104,7 @@ export class ManagedSessionDescendantStore {
|
|
|
925
1104
|
|
|
926
1105
|
/** Remove an exact captured file without reopening its pathname as authority. */
|
|
927
1106
|
removeExpected(relativePath: string, expected: ManagedFileSnapshot): void {
|
|
1107
|
+
this.#beforeMutation();
|
|
928
1108
|
this.#assertBound();
|
|
929
1109
|
if (!this.#authority) {
|
|
930
1110
|
const removed = exactUnlink(this.#resolve(relativePath), {
|
|
@@ -964,6 +1144,7 @@ export class ManagedSessionDescendantStore {
|
|
|
964
1144
|
}
|
|
965
1145
|
/** Read and remove one managed descendant through retained authority. */
|
|
966
1146
|
async consume(relativePath: string): Promise<Uint8Array | null> {
|
|
1147
|
+
this.#beforeMutation();
|
|
967
1148
|
this.#assertBound();
|
|
968
1149
|
const resolved = this.#resolve(relativePath);
|
|
969
1150
|
if (this.#authority) {
|
|
@@ -1002,6 +1183,7 @@ export class ManagedSessionDescendantStore {
|
|
|
1002
1183
|
|
|
1003
1184
|
/** Remove one managed descendant through retained authority when it exists. */
|
|
1004
1185
|
async remove(relativePath: string): Promise<void> {
|
|
1186
|
+
this.#beforeMutation();
|
|
1005
1187
|
await this.consume(relativePath);
|
|
1006
1188
|
}
|
|
1007
1189
|
|
|
@@ -1025,6 +1207,7 @@ export class ManagedSessionDescendantStore {
|
|
|
1025
1207
|
destinationRelativePath: string,
|
|
1026
1208
|
snapshot: NativeDirectoryTreeSnapshot,
|
|
1027
1209
|
): Promise<void> {
|
|
1210
|
+
this.#beforeMutation();
|
|
1028
1211
|
const actual = this.captureTree(sourceRelativePath);
|
|
1029
1212
|
if (JSON.stringify(actual) !== JSON.stringify(snapshot)) throw new Error("artifact_source_changed");
|
|
1030
1213
|
this.ensureDirectory(destinationRelativePath);
|
|
@@ -1071,6 +1254,7 @@ export class ManagedSessionDescendantStore {
|
|
|
1071
1254
|
destinationRelativePath: string,
|
|
1072
1255
|
expected: NativeDirectoryTreeSnapshot,
|
|
1073
1256
|
): NativeDirectoryTreeSnapshot {
|
|
1257
|
+
this.#beforeMutation();
|
|
1074
1258
|
this.#assertBound();
|
|
1075
1259
|
const moved = this.#authority
|
|
1076
1260
|
? this.#authority.renameManagedTreeNoReplace(
|
|
@@ -1098,6 +1282,7 @@ export class ManagedSessionDescendantStore {
|
|
|
1098
1282
|
}
|
|
1099
1283
|
|
|
1100
1284
|
removeTreeExpected(relativePath: string, expected: NativeDirectoryTreeSnapshot): void {
|
|
1285
|
+
this.#beforeMutation();
|
|
1101
1286
|
this.#assertBound();
|
|
1102
1287
|
if (!this.#authority) {
|
|
1103
1288
|
const removed = exactRemoveDirectoryTree(this.#resolve(relativePath), expected);
|
|
@@ -1110,6 +1295,7 @@ export class ManagedSessionDescendantStore {
|
|
|
1110
1295
|
this.#assertBound();
|
|
1111
1296
|
}
|
|
1112
1297
|
fsyncTree(): NativeDirectoryTreeSnapshot {
|
|
1298
|
+
this.#beforeMutation();
|
|
1113
1299
|
this.#assertBound();
|
|
1114
1300
|
if (!this.#authority) return fsyncManagedArtifactTree(this.#baseDir);
|
|
1115
1301
|
const baseRelative = this.#relative(this.#baseDir);
|
|
@@ -1205,6 +1391,7 @@ function identity(stat: fs.BigIntStats, sha256 = ""): ManagedFileSnapshot["ident
|
|
|
1205
1391
|
return {
|
|
1206
1392
|
dev: stat.dev,
|
|
1207
1393
|
ino: stat.ino,
|
|
1394
|
+
nlink: stat.nlink,
|
|
1208
1395
|
size: Number(stat.size),
|
|
1209
1396
|
mtimeNs: stat.mtimeNs,
|
|
1210
1397
|
ctimeNs: stat.ctimeNs,
|
|
@@ -1216,6 +1403,7 @@ function sameIdentity(left: ManagedFileSnapshot["identity"], right: ManagedFileS
|
|
|
1216
1403
|
return (
|
|
1217
1404
|
left.dev === right.dev &&
|
|
1218
1405
|
left.ino === right.ino &&
|
|
1406
|
+
left.nlink === right.nlink &&
|
|
1219
1407
|
left.size === right.size &&
|
|
1220
1408
|
left.mtimeNs === right.mtimeNs &&
|
|
1221
1409
|
left.ctimeNs === right.ctimeNs
|
|
@@ -1240,11 +1428,9 @@ function isCtimeOnlyIdentityMismatch(
|
|
|
1240
1428
|
return sameStableIdentityIgnoringCtime(observed, expected) && observed.ctimeNs !== expected.ctimeNs;
|
|
1241
1429
|
}
|
|
1242
1430
|
|
|
1243
|
-
function
|
|
1431
|
+
function parseLockBytes(bytes: Uint8Array): LockRecord | undefined {
|
|
1244
1432
|
try {
|
|
1245
|
-
const
|
|
1246
|
-
if (!stat.isFile() || stat.isSymbolicLink()) return undefined;
|
|
1247
|
-
const value: unknown = JSON.parse(fs.readFileSync(pathname, "utf8"));
|
|
1433
|
+
const value: unknown = JSON.parse(Buffer.from(bytes).toString("utf8"));
|
|
1248
1434
|
if (!value || typeof value !== "object") return undefined;
|
|
1249
1435
|
const record = value as Partial<LockRecord>;
|
|
1250
1436
|
return typeof record.attemptId === "string" &&
|
|
@@ -1252,7 +1438,8 @@ function parseLock(pathname: string): LockRecord | undefined {
|
|
|
1252
1438
|
typeof record.processStartId === "string" &&
|
|
1253
1439
|
typeof record.leaseExpiresAt === "number" &&
|
|
1254
1440
|
typeof record.heartbeatAt === "number" &&
|
|
1255
|
-
typeof record.createdAt === "number"
|
|
1441
|
+
typeof record.createdAt === "number" &&
|
|
1442
|
+
(record.released === undefined || typeof record.released === "boolean")
|
|
1256
1443
|
? (record as LockRecord)
|
|
1257
1444
|
: undefined;
|
|
1258
1445
|
} catch {
|
|
@@ -1260,6 +1447,26 @@ function parseLock(pathname: string): LockRecord | undefined {
|
|
|
1260
1447
|
}
|
|
1261
1448
|
}
|
|
1262
1449
|
|
|
1450
|
+
function parseLock(pathname: string): LockRecord | undefined {
|
|
1451
|
+
try {
|
|
1452
|
+
const stat = fs.lstatSync(pathname);
|
|
1453
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return undefined;
|
|
1454
|
+
return parseLockBytes(fs.readFileSync(pathname));
|
|
1455
|
+
} catch {
|
|
1456
|
+
return undefined;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function captureLock(pathname: string): { record: LockRecord; snapshot: ManagedFileSnapshot } | undefined {
|
|
1461
|
+
try {
|
|
1462
|
+
const snapshot = captureManagedFileNoFollow(pathname);
|
|
1463
|
+
const record = parseLockBytes(snapshot.bytes);
|
|
1464
|
+
return record ? { record, snapshot } : undefined;
|
|
1465
|
+
} catch {
|
|
1466
|
+
return undefined;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1263
1470
|
function ownerDefinitelyGone(record: LockRecord): boolean {
|
|
1264
1471
|
if (record.bootId && bootId() && record.bootId !== bootId()) return true;
|
|
1265
1472
|
try {
|
|
@@ -1493,12 +1700,15 @@ export function replaceManagedFileSync(
|
|
|
1493
1700
|
bytes: Uint8Array,
|
|
1494
1701
|
root: ManagedDirectoryRoot,
|
|
1495
1702
|
policy: ManagedSessionSecurityPolicy = "default",
|
|
1703
|
+
assertFence?: () => void,
|
|
1704
|
+
expectedDestination?: ManagedFileSnapshot["identity"],
|
|
1496
1705
|
): void {
|
|
1497
1706
|
const parent = path.dirname(destination);
|
|
1498
1707
|
ensureManagedDirectory(parent, root, policy);
|
|
1499
1708
|
const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.replacement`);
|
|
1500
1709
|
let fd: number | undefined;
|
|
1501
1710
|
let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
|
|
1711
|
+
let preserveStaging = false;
|
|
1502
1712
|
try {
|
|
1503
1713
|
fd = fs.openSync(
|
|
1504
1714
|
staging,
|
|
@@ -1512,20 +1722,137 @@ export function replaceManagedFileSync(
|
|
|
1512
1722
|
secureFileDescriptor(staging, fd, "verify");
|
|
1513
1723
|
const staged = fs.fstatSync(fd, { bigint: true });
|
|
1514
1724
|
stagedIdentity = { dev: staged.dev, ino: staged.ino };
|
|
1725
|
+
if (process.platform === "win32" && expectedDestination) {
|
|
1726
|
+
fs.closeSync(fd);
|
|
1727
|
+
fd = undefined;
|
|
1728
|
+
}
|
|
1515
1729
|
assertManagedDirectoryRoot(root);
|
|
1516
|
-
|
|
1730
|
+
assertFence?.();
|
|
1731
|
+
if (process.platform !== "win32" && expectedDestination) {
|
|
1732
|
+
throw new Error("managed_replace_exact_unavailable");
|
|
1733
|
+
}
|
|
1734
|
+
if (process.platform === "win32" && expectedDestination) {
|
|
1735
|
+
const parentIdentity = fs.lstatSync(parent, { bigint: true });
|
|
1736
|
+
const replaced = exactReplacePath(
|
|
1737
|
+
staging,
|
|
1738
|
+
destination,
|
|
1739
|
+
{
|
|
1740
|
+
dev: staged.dev,
|
|
1741
|
+
ino: staged.ino,
|
|
1742
|
+
nlink: staged.nlink,
|
|
1743
|
+
parentDev: parentIdentity.dev,
|
|
1744
|
+
parentIno: parentIdentity.ino,
|
|
1745
|
+
size: staged.size,
|
|
1746
|
+
mtimeNs: staged.mtimeNs,
|
|
1747
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
1748
|
+
},
|
|
1749
|
+
{
|
|
1750
|
+
dev: expectedDestination.dev,
|
|
1751
|
+
ino: expectedDestination.ino,
|
|
1752
|
+
nlink: expectedDestination.nlink,
|
|
1753
|
+
parentDev: parentIdentity.dev,
|
|
1754
|
+
parentIno: parentIdentity.ino,
|
|
1755
|
+
size: BigInt(expectedDestination.size),
|
|
1756
|
+
mtimeNs: expectedDestination.mtimeNs,
|
|
1757
|
+
sha256: expectedDestination.sha256,
|
|
1758
|
+
},
|
|
1759
|
+
);
|
|
1760
|
+
if (!replaced.ok) {
|
|
1761
|
+
const committedWithPredecessor =
|
|
1762
|
+
replaced.detachedPath === replaced.retainedPlaceholderPath &&
|
|
1763
|
+
replaced.retainedSuccessorPath === destination;
|
|
1764
|
+
if (committedWithPredecessor) {
|
|
1765
|
+
const retired = exactUnlink(replaced.retainedPlaceholderPath!, {
|
|
1766
|
+
dev: expectedDestination.dev,
|
|
1767
|
+
ino: expectedDestination.ino,
|
|
1768
|
+
nlink: expectedDestination.nlink,
|
|
1769
|
+
parentDev: parentIdentity.dev,
|
|
1770
|
+
parentIno: parentIdentity.ino,
|
|
1771
|
+
size: BigInt(expectedDestination.size),
|
|
1772
|
+
mtimeNs: expectedDestination.mtimeNs,
|
|
1773
|
+
sha256: expectedDestination.sha256,
|
|
1774
|
+
quarantineName: `.gjc-replace-cleanup-${expectedDestination.dev.toString(16)}-${expectedDestination.ino.toString(16)}`,
|
|
1775
|
+
});
|
|
1776
|
+
if (!retired.ok) {
|
|
1777
|
+
const predecessor =
|
|
1778
|
+
retired.detachedPath ?? retired.retainedPlaceholderPath ?? replaced.retainedPlaceholderPath;
|
|
1779
|
+
const receiptPath = path.join(
|
|
1780
|
+
parent,
|
|
1781
|
+
`.gjc-replace-cleanup-${expectedDestination.dev.toString(16)}-${expectedDestination.ino.toString(16)}.json`,
|
|
1782
|
+
);
|
|
1783
|
+
publishManagedFileNoReplaceSync(
|
|
1784
|
+
receiptPath,
|
|
1785
|
+
Buffer.from(
|
|
1786
|
+
JSON.stringify({
|
|
1787
|
+
version: 1,
|
|
1788
|
+
predecessor,
|
|
1789
|
+
successor: destination,
|
|
1790
|
+
identity: {
|
|
1791
|
+
dev: expectedDestination.dev.toString(),
|
|
1792
|
+
ino: expectedDestination.ino.toString(),
|
|
1793
|
+
nlink: expectedDestination.nlink.toString(),
|
|
1794
|
+
size: String(expectedDestination.size),
|
|
1795
|
+
mtimeNs: expectedDestination.mtimeNs.toString(),
|
|
1796
|
+
ctimeNs: expectedDestination.ctimeNs.toString(),
|
|
1797
|
+
sha256: expectedDestination.sha256,
|
|
1798
|
+
},
|
|
1799
|
+
}),
|
|
1800
|
+
),
|
|
1801
|
+
root,
|
|
1802
|
+
policy,
|
|
1803
|
+
);
|
|
1804
|
+
throw new Error(
|
|
1805
|
+
`managed_replace_cleanup_pending:receipt=${receiptPath}:predecessor=${predecessor}:successor=${destination}:code=${retired.code ?? "unknown"}`,
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
if (!committedWithPredecessor) {
|
|
1810
|
+
if (replaced.detachedPath) {
|
|
1811
|
+
const restored = exactRestore(replaced.detachedPath, staging, {
|
|
1812
|
+
dev: staged.dev,
|
|
1813
|
+
ino: staged.ino,
|
|
1814
|
+
nlink: staged.nlink,
|
|
1815
|
+
parentDev: parentIdentity.dev,
|
|
1816
|
+
parentIno: parentIdentity.ino,
|
|
1817
|
+
size: staged.size,
|
|
1818
|
+
mtimeNs: staged.mtimeNs,
|
|
1819
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
1820
|
+
});
|
|
1821
|
+
if (restored.ok) preserveStaging = true;
|
|
1822
|
+
if (!restored.ok)
|
|
1823
|
+
throw new Error(
|
|
1824
|
+
`managed_replace_failed:${replaced.code ?? "unknown"}:retained=${replaced.detachedPath}:restore=${restored.code ?? "unknown"}`,
|
|
1825
|
+
);
|
|
1826
|
+
}
|
|
1827
|
+
const retained = [
|
|
1828
|
+
replaced.detachedPath,
|
|
1829
|
+
replaced.retainedSuccessorPath,
|
|
1830
|
+
replaced.retainedPlaceholderPath,
|
|
1831
|
+
replaced.retainedUnknownPath,
|
|
1832
|
+
]
|
|
1833
|
+
.filter((pathname): pathname is string => typeof pathname === "string")
|
|
1834
|
+
.join(",");
|
|
1835
|
+
throw new Error(
|
|
1836
|
+
`managed_replace_failed:${replaced.code ?? "unknown"}${retained ? `:retained=${retained}` : ""}`,
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
} else fs.renameSync(staging, destination);
|
|
1841
|
+
assertFence?.();
|
|
1517
1842
|
assertManagedDirectoryRoot(root);
|
|
1518
1843
|
const named = fs.lstatSync(destination, { bigint: true });
|
|
1519
1844
|
if (!named.isFile() || named.isSymbolicLink() || named.dev !== staged.dev || named.ino !== staged.ino) {
|
|
1520
1845
|
throw new Error("destination_identity_changed");
|
|
1521
1846
|
}
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1847
|
+
if (fd !== undefined) {
|
|
1848
|
+
secureFileDescriptor(destination, fd, "verify");
|
|
1849
|
+
fs.closeSync(fd);
|
|
1850
|
+
fd = undefined;
|
|
1851
|
+
}
|
|
1525
1852
|
fsyncDirectory(parent);
|
|
1526
1853
|
} finally {
|
|
1527
1854
|
if (fd !== undefined) fs.closeSync(fd);
|
|
1528
|
-
if (stagedIdentity) {
|
|
1855
|
+
if (stagedIdentity && !preserveStaging) {
|
|
1529
1856
|
try {
|
|
1530
1857
|
const named = fs.lstatSync(staging, { bigint: true });
|
|
1531
1858
|
if (named.dev === stagedIdentity.dev && named.ino === stagedIdentity.ino) fs.unlinkSync(staging);
|
|
@@ -1571,7 +1898,6 @@ export async function acquireManagedLock(
|
|
|
1571
1898
|
ensureManagedDirectory(locksDirectory, root, policy);
|
|
1572
1899
|
const lockPath = path.join(locksDirectory, `${name}.lock`);
|
|
1573
1900
|
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
1574
|
-
let staleObservedAt: number | undefined;
|
|
1575
1901
|
while (true) {
|
|
1576
1902
|
const attemptId = randomUUID();
|
|
1577
1903
|
const now = Date.now();
|
|
@@ -1604,12 +1930,8 @@ export async function acquireManagedLock(
|
|
|
1604
1930
|
let descriptorClosed = false;
|
|
1605
1931
|
const closeDescriptor = (): void => {
|
|
1606
1932
|
if (descriptorClosed) return;
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
} finally {
|
|
1610
|
-
fs.closeSync(fd);
|
|
1611
|
-
descriptorClosed = true;
|
|
1612
|
-
}
|
|
1933
|
+
fs.closeSync(fd);
|
|
1934
|
+
descriptorClosed = true;
|
|
1613
1935
|
};
|
|
1614
1936
|
const assertOwned = (): void => {
|
|
1615
1937
|
const current = parseLock(lockPath);
|
|
@@ -1623,17 +1945,23 @@ export async function acquireManagedLock(
|
|
|
1623
1945
|
released ||
|
|
1624
1946
|
descriptorClosed ||
|
|
1625
1947
|
!current ||
|
|
1948
|
+
current.released === true ||
|
|
1626
1949
|
!sameFileIdentity(lockIdentity, named) ||
|
|
1627
|
-
current.attemptId !== attemptId
|
|
1628
|
-
current.leaseExpiresAt < Date.now()
|
|
1950
|
+
current.attemptId !== attemptId
|
|
1629
1951
|
)
|
|
1630
1952
|
throw new Error("migration_busy");
|
|
1953
|
+
const now = Date.now();
|
|
1954
|
+
if (current.leaseExpiresAt < now + LOCK_HEARTBEAT_MS) {
|
|
1955
|
+
writeLockDescriptor(fd, {
|
|
1956
|
+
...record,
|
|
1957
|
+
heartbeatAt: now,
|
|
1958
|
+
leaseExpiresAt: now + LOCK_LEASE_MS,
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1631
1961
|
};
|
|
1632
1962
|
const heartbeat = setInterval(() => {
|
|
1633
1963
|
try {
|
|
1634
1964
|
assertOwned();
|
|
1635
|
-
const now = Date.now();
|
|
1636
|
-
writeLockDescriptor(fd, { ...record, heartbeatAt: now, leaseExpiresAt: now + LOCK_LEASE_MS });
|
|
1637
1965
|
} catch {
|
|
1638
1966
|
/* fencing rejects later publication */
|
|
1639
1967
|
}
|
|
@@ -1646,10 +1974,16 @@ export async function acquireManagedLock(
|
|
|
1646
1974
|
clearInterval(heartbeat);
|
|
1647
1975
|
try {
|
|
1648
1976
|
assertOwned();
|
|
1977
|
+
secureFileDescriptor(lockPath, fd, "verify");
|
|
1649
1978
|
const now = Date.now();
|
|
1650
|
-
//
|
|
1651
|
-
//
|
|
1652
|
-
writeLockDescriptor(fd, {
|
|
1979
|
+
// A released record is the only live-process reclaim authority. Expiry alone
|
|
1980
|
+
// never authorizes stealing from a holder whose process is still present.
|
|
1981
|
+
writeLockDescriptor(fd, {
|
|
1982
|
+
...record,
|
|
1983
|
+
released: true,
|
|
1984
|
+
heartbeatAt: now,
|
|
1985
|
+
leaseExpiresAt: now,
|
|
1986
|
+
});
|
|
1653
1987
|
fsyncDirectory(locksDirectory);
|
|
1654
1988
|
} finally {
|
|
1655
1989
|
released = true;
|
|
@@ -1659,24 +1993,27 @@ export async function acquireManagedLock(
|
|
|
1659
1993
|
};
|
|
1660
1994
|
} catch (error) {
|
|
1661
1995
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
1662
|
-
const
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1996
|
+
const observed = captureLock(lockPath);
|
|
1997
|
+
const owner = observed?.record;
|
|
1998
|
+
const reclaimable =
|
|
1999
|
+
owner?.released === true ||
|
|
2000
|
+
(owner !== undefined && owner.leaseExpiresAt < Date.now() && ownerDefinitelyGone(owner));
|
|
2001
|
+
if (observed && owner && reclaimable) {
|
|
2002
|
+
try {
|
|
2003
|
+
ManagedLockTestHooks.beforeObservedRetirement?.({ path: lockPath, attemptId: owner.attemptId });
|
|
2004
|
+
const removed = exactUnlink(lockPath, {
|
|
2005
|
+
dev: observed.snapshot.identity.dev,
|
|
2006
|
+
ino: observed.snapshot.identity.ino,
|
|
2007
|
+
size: BigInt(observed.snapshot.identity.size),
|
|
2008
|
+
mtimeNs: observed.snapshot.identity.mtimeNs,
|
|
2009
|
+
sha256: observed.snapshot.identity.sha256,
|
|
2010
|
+
quarantineName: `.gjc-lock-${randomUUID()}.stale`,
|
|
2011
|
+
});
|
|
2012
|
+
if (removed.ok || removed.code === "cleanup_pending") fsyncDirectory(locksDirectory);
|
|
2013
|
+
} catch {
|
|
2014
|
+
/* retry owner observation */
|
|
1678
2015
|
}
|
|
1679
|
-
}
|
|
2016
|
+
}
|
|
1680
2017
|
if (Date.now() >= deadline) throw new Error("migration_busy");
|
|
1681
2018
|
await new Promise<void>(resolve => setTimeout(resolve, 50));
|
|
1682
2019
|
}
|
|
@@ -48,6 +48,7 @@ export type NativePublishIdentity = {
|
|
|
48
48
|
readonly dev: string;
|
|
49
49
|
readonly ino: string;
|
|
50
50
|
readonly size: string;
|
|
51
|
+
readonly nlink?: string;
|
|
51
52
|
readonly mtimeNs: string;
|
|
52
53
|
readonly ctimeNs: string;
|
|
53
54
|
readonly sha256?: string;
|
|
@@ -126,12 +127,13 @@ const malformed: NativePublishOutcome = Object.freeze({
|
|
|
126
127
|
|
|
127
128
|
function validIdentity(value: unknown): boolean {
|
|
128
129
|
if (value === undefined) return true;
|
|
129
|
-
if (!ownPlainRecord(value) || !exactKeys(value, ["dev", "ino", "size", "mtimeNs", "ctimeNs", "sha256"]))
|
|
130
|
+
if (!ownPlainRecord(value) || !exactKeys(value, ["dev", "ino", "nlink", "size", "mtimeNs", "ctimeNs", "sha256"]))
|
|
130
131
|
return false;
|
|
131
132
|
const decimal = (field: unknown) => typeof field === "string" && /^-?[0-9]{1,32}$/.test(field);
|
|
132
133
|
return (
|
|
133
134
|
decimal(value.dev) &&
|
|
134
135
|
decimal(value.ino) &&
|
|
136
|
+
(value.nlink === undefined || decimal(value.nlink)) &&
|
|
135
137
|
decimal(value.size) &&
|
|
136
138
|
decimal(value.mtimeNs) &&
|
|
137
139
|
decimal(value.ctimeNs) &&
|