@stigmer/server 3.22.1 → 3.23.0
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/dist/artifactstorage/artifact-storage.d.ts +29 -31
- package/dist/artifactstorage/artifact-storage.d.ts.map +1 -1
- package/dist/artifactstorage/artifact-storage.js +5 -7
- package/dist/artifactstorage/artifact-storage.js.map +1 -1
- package/dist/artifactstorage/r2-storage.d.ts +12 -7
- package/dist/artifactstorage/r2-storage.d.ts.map +1 -1
- package/dist/artifactstorage/r2-storage.js +6 -14
- package/dist/artifactstorage/r2-storage.js.map +1 -1
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +49 -24
- package/dist/boot/compose.js.map +1 -1
- package/dist/boot/config.d.ts +4 -3
- package/dist/boot/config.d.ts.map +1 -1
- package/dist/boot/config.js.map +1 -1
- package/dist/boot/skill-transfer-origin.d.ts +52 -0
- package/dist/boot/skill-transfer-origin.d.ts.map +1 -0
- package/dist/boot/skill-transfer-origin.js +54 -0
- package/dist/boot/skill-transfer-origin.js.map +1 -0
- package/dist/domain/plugin/controller.d.ts +3 -7
- package/dist/domain/plugin/controller.d.ts.map +1 -1
- package/dist/domain/plugin/controller.js +8 -8
- package/dist/domain/plugin/controller.js.map +1 -1
- package/dist/domain/skill/constants.d.ts +26 -3
- package/dist/domain/skill/constants.d.ts.map +1 -1
- package/dist/domain/skill/constants.js +26 -3
- package/dist/domain/skill/constants.js.map +1 -1
- package/dist/domain/skill/controller.d.ts +6 -11
- package/dist/domain/skill/controller.d.ts.map +1 -1
- package/dist/domain/skill/controller.js +33 -22
- package/dist/domain/skill/controller.js.map +1 -1
- package/dist/domain/skill/push.d.ts +2 -2
- package/dist/domain/skill/push.d.ts.map +1 -1
- package/dist/domain/skill/push.js +2 -2
- package/dist/domain/skill/push.js.map +1 -1
- package/dist/domain/skill/transfer/handler.d.ts +17 -9
- package/dist/domain/skill/transfer/handler.d.ts.map +1 -1
- package/dist/domain/skill/transfer/handler.js +19 -8
- package/dist/domain/skill/transfer/handler.js.map +1 -1
- package/dist/domain/skill/transfer/slots.d.ts +26 -41
- package/dist/domain/skill/transfer/slots.d.ts.map +1 -1
- package/dist/domain/skill/transfer/slots.js +80 -87
- package/dist/domain/skill/transfer/slots.js.map +1 -1
- package/dist/domain/skill/transfer/staging.d.ts +48 -0
- package/dist/domain/skill/transfer/staging.d.ts.map +1 -0
- package/dist/domain/skill/transfer/staging.js +127 -0
- package/dist/domain/skill/transfer/staging.js.map +1 -0
- package/dist/pipeline/steps/resolve-artifact-source.d.ts +11 -8
- package/dist/pipeline/steps/resolve-artifact-source.d.ts.map +1 -1
- package/dist/pipeline/steps/resolve-artifact-source.js +8 -4
- package/dist/pipeline/steps/resolve-artifact-source.js.map +1 -1
- package/package.json +6 -6
- package/src/artifactstorage/__tests__/artifact-storage.test.ts +36 -33
- package/src/artifactstorage/__tests__/r2-storage.test.ts +11 -13
- package/src/artifactstorage/artifact-storage.ts +37 -33
- package/src/artifactstorage/r2-storage.ts +6 -15
- package/src/boot/__tests__/skill-transfer-origin.test.ts +72 -0
- package/src/boot/compose.ts +62 -25
- package/src/boot/config.ts +4 -3
- package/src/boot/skill-transfer-origin.ts +76 -0
- package/src/domain/plugin/controller.ts +15 -17
- package/src/domain/skill/__tests__/skill.test.ts +5 -3
- package/src/domain/skill/__tests__/slots.test.ts +113 -60
- package/src/domain/skill/__tests__/staging.test.ts +304 -0
- package/src/domain/skill/constants.ts +28 -3
- package/src/domain/skill/controller.ts +44 -35
- package/src/domain/skill/push.ts +3 -3
- package/src/domain/skill/transfer/handler.ts +33 -15
- package/src/domain/skill/transfer/slots.ts +89 -100
- package/src/domain/skill/transfer/staging.ts +191 -0
- package/src/pipeline/steps/resolve-artifact-source.ts +20 -13
- package/src/transport/registry/__tests__/lanes.test.ts +1 -1
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Skill artifact upload slots —
|
|
3
|
-
*
|
|
4
|
-
* staging
|
|
2
|
+
* Skill artifact upload slots — the local blob driver's staging mechanism,
|
|
3
|
+
* ported from pkg/domain/skill/transfer/slots.go: an in-memory registry of
|
|
4
|
+
* single-use upload capabilities plus the staging files their bytes land
|
|
5
|
+
* in (#675).
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
+
* A slot is keyed by the STAGING KEY the domain names (`skills/staging/
|
|
8
|
+
* <hex>.zip`, transfer/staging.ts), and its file is that key resolved
|
|
9
|
+
* under the blob driver's root — so the driver's own download(key) reads
|
|
10
|
+
* what the lane received and its delete(key) retires the bytes, exactly
|
|
11
|
+
* as a bucket driver's would. The registry therefore knows nothing of
|
|
12
|
+
* consumption: it reserves, receives, and sweeps; the domain reads and
|
|
13
|
+
* deletes through the driver, one path for every backend.
|
|
14
|
+
*
|
|
15
|
+
* In-memory is deliberate: the local driver is single-instance (the same
|
|
7
16
|
* assumption the in-process router transport and SQLite store already
|
|
8
17
|
* make), and a slot is worthless across restarts anyway — its bytes live
|
|
9
18
|
* in the staging directory, which is swept on boot.
|
|
@@ -11,20 +20,16 @@
|
|
|
11
20
|
* Sentinel error classes replace Go's sentinel error values so the HTTP
|
|
12
21
|
* handler maps registry failures onto honest status codes without string
|
|
13
22
|
* matching; their message texts are Go's, verbatim (they ride wire-visible
|
|
14
|
-
* copy:
|
|
15
|
-
* body).
|
|
23
|
+
* copy: the handler's 404, 409 and 400 bodies).
|
|
16
24
|
*
|
|
17
25
|
* Proven by __tests__/slots.test.ts (injected clock for expiry) and the
|
|
18
26
|
* conformance suite's transfer-lane tests.
|
|
19
27
|
*/
|
|
20
|
-
import { randomBytes } from "node:crypto";
|
|
21
28
|
import fs from "node:fs";
|
|
22
29
|
import path from "node:path";
|
|
23
30
|
import { pipeline } from "node:stream/promises";
|
|
24
31
|
import type { Readable } from "node:stream";
|
|
25
32
|
|
|
26
|
-
import { REF_BYTE_LEN, REF_PREFIX } from "../constants.js";
|
|
27
|
-
|
|
28
33
|
/** Go errSlotUnknown — never existed, expired, or swept. */
|
|
29
34
|
export class SlotUnknownError extends Error {
|
|
30
35
|
constructor() {
|
|
@@ -41,18 +46,12 @@ export class SlotConsumedError extends Error {
|
|
|
41
46
|
}
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
/** Go
|
|
45
|
-
export class SlotEmptyError extends Error {
|
|
46
|
-
constructor() {
|
|
47
|
-
super("upload reference has no uploaded bytes");
|
|
48
|
-
this.name = "SlotEmptyError";
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Go errSizeMismatch — the body disagreed with the minted declaration. */
|
|
49
|
+
/** Go errSizeMismatch — the body disagreed with the reserved declaration. */
|
|
53
50
|
export class SizeMismatchError extends Error {
|
|
54
51
|
constructor(received: number, declared: number) {
|
|
55
|
-
super(
|
|
52
|
+
super(
|
|
53
|
+
`upload size mismatch: received ${received} bytes, declared ${declared}`,
|
|
54
|
+
);
|
|
56
55
|
this.name = "SizeMismatchError";
|
|
57
56
|
}
|
|
58
57
|
}
|
|
@@ -63,65 +62,76 @@ interface Slot {
|
|
|
63
62
|
uploaded: boolean;
|
|
64
63
|
}
|
|
65
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The one file-name shape a staging key may carry past its prefix: the
|
|
67
|
+
* domain's hex reference plus the archive suffix. Keys are server-minted,
|
|
68
|
+
* never client-supplied, so this is an invariant check, not input
|
|
69
|
+
* validation — a violation is a programming error and throws.
|
|
70
|
+
*/
|
|
71
|
+
const STAGED_FILE_NAME = /^[0-9a-f]+\.zip$/;
|
|
72
|
+
|
|
66
73
|
export class UploadSlots {
|
|
67
74
|
private readonly slots = new Map<string, Slot>();
|
|
68
75
|
private readonly stagingDir: string;
|
|
69
|
-
private readonly ttlMs: number;
|
|
70
|
-
private readonly maxSize: number;
|
|
71
76
|
/** Injectable for expiry tests, mirroring Go's `now` field. */
|
|
72
77
|
private readonly now: () => number;
|
|
73
78
|
|
|
74
79
|
/**
|
|
75
|
-
* Creates the registry
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* never reached their
|
|
80
|
+
* Creates the registry over `root` (the local blob driver's root) and
|
|
81
|
+
* prepares the staging directory `root/stagingPrefix`. Any file already
|
|
82
|
+
* present is an orphan from a previous process (the registry that knew
|
|
83
|
+
* about it died with that process), so the directory is emptied — this
|
|
84
|
+
* is also the crash-recovery story for uploads that never reached their
|
|
85
|
+
* push.
|
|
80
86
|
*/
|
|
81
87
|
constructor(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
private readonly root: string,
|
|
89
|
+
private readonly stagingPrefix: string,
|
|
90
|
+
private readonly ttlMs: number,
|
|
91
|
+
private readonly maxSize: number,
|
|
85
92
|
now: () => number = Date.now,
|
|
86
93
|
) {
|
|
87
|
-
|
|
88
|
-
fs.
|
|
89
|
-
this.stagingDir
|
|
90
|
-
this.ttlMs = ttlMs;
|
|
91
|
-
this.maxSize = maxSize;
|
|
94
|
+
this.stagingDir = path.join(root, stagingPrefix);
|
|
95
|
+
fs.rmSync(this.stagingDir, { recursive: true, force: true });
|
|
96
|
+
fs.mkdirSync(this.stagingDir, { recursive: true, mode: 0o700 });
|
|
92
97
|
this.now = now;
|
|
93
98
|
}
|
|
94
99
|
|
|
95
100
|
/**
|
|
96
|
-
* Reserves an upload slot for an artifact of declaredSize bytes
|
|
97
|
-
*
|
|
101
|
+
* Reserves an upload slot for an artifact of declaredSize bytes at the
|
|
102
|
+
* staging key; returns the TTL granted. The caller has already
|
|
98
103
|
* authorized the request and validated declaredSize against the skill
|
|
99
|
-
* size limit;
|
|
104
|
+
* size limit; the bound here is the registry's own invariant.
|
|
100
105
|
*/
|
|
101
|
-
|
|
106
|
+
reserve(key: string, declaredSize: number): { ttlMs: number } {
|
|
102
107
|
if (declaredSize <= 0 || declaredSize > this.maxSize) {
|
|
103
|
-
throw new Error(
|
|
108
|
+
throw new Error(
|
|
109
|
+
`declared size ${declaredSize} outside (0, ${this.maxSize}]`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
this.stagePath(key);
|
|
113
|
+
if (this.slots.has(key)) {
|
|
114
|
+
throw new Error(`staging key ${key} is already reserved`);
|
|
104
115
|
}
|
|
105
|
-
const ref = REF_PREFIX + randomBytes(REF_BYTE_LEN).toString("hex");
|
|
106
116
|
this.sweep();
|
|
107
|
-
this.slots.set(
|
|
117
|
+
this.slots.set(key, {
|
|
108
118
|
declaredSize,
|
|
109
119
|
expiresAtMs: this.now() + this.ttlMs,
|
|
110
120
|
uploaded: false,
|
|
111
121
|
});
|
|
112
|
-
return {
|
|
122
|
+
return { ttlMs: this.ttlMs };
|
|
113
123
|
}
|
|
114
124
|
|
|
115
125
|
/**
|
|
116
126
|
* Streams an upload's body into the slot's staging file. The body must
|
|
117
|
-
* match the size declared at
|
|
127
|
+
* match the size declared at reservation exactly: a shorter body means a
|
|
118
128
|
* truncated transfer, a longer one means the client lied — both reject
|
|
119
129
|
* rather than staging bytes that would fail (or worse, surprise)
|
|
120
|
-
* validation later. The staged file only becomes
|
|
121
|
-
* resolves.
|
|
130
|
+
* validation later. The staged file only becomes readable through the
|
|
131
|
+
* driver once this resolves.
|
|
122
132
|
*/
|
|
123
|
-
async receive(
|
|
124
|
-
const slot = this.slots.get(
|
|
133
|
+
async receive(key: string, body: Readable): Promise<void> {
|
|
134
|
+
const slot = this.slots.get(key);
|
|
125
135
|
if (slot === undefined || this.now() > slot.expiresAtMs) {
|
|
126
136
|
throw new SlotUnknownError();
|
|
127
137
|
}
|
|
@@ -130,9 +140,16 @@ export class UploadSlots {
|
|
|
130
140
|
}
|
|
131
141
|
const declared = slot.declaredSize;
|
|
132
142
|
|
|
133
|
-
const filePath = this.stagePath(
|
|
143
|
+
const filePath = this.stagePath(key);
|
|
134
144
|
let written = 0;
|
|
135
145
|
try {
|
|
146
|
+
// The driver's delete(key) prunes directories it empties, so the
|
|
147
|
+
// staging directory may be gone between two uploads; recreate it
|
|
148
|
+
// rather than fail the second one.
|
|
149
|
+
await fs.promises.mkdir(path.dirname(filePath), {
|
|
150
|
+
recursive: true,
|
|
151
|
+
mode: 0o700,
|
|
152
|
+
});
|
|
136
153
|
// Consume at most declared+1 bytes: seeing the extra byte proves the
|
|
137
154
|
// body exceeds the declaration without buffering an unbounded stream
|
|
138
155
|
// (Go's io.LimitReader(declared+1) + written != declared check).
|
|
@@ -157,7 +174,7 @@ export class UploadSlots {
|
|
|
157
174
|
|
|
158
175
|
// Re-check after the write: the slot may have expired mid-upload
|
|
159
176
|
// (Go re-checks under the lock for the same reason).
|
|
160
|
-
const current = this.slots.get(
|
|
177
|
+
const current = this.slots.get(key);
|
|
161
178
|
if (current === undefined || this.now() > current.expiresAtMs) {
|
|
162
179
|
await fs.promises.rm(filePath, { force: true });
|
|
163
180
|
throw new SlotUnknownError();
|
|
@@ -166,66 +183,35 @@ export class UploadSlots {
|
|
|
166
183
|
}
|
|
167
184
|
|
|
168
185
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
* artifact_upload_ref; whatever happens downstream (validation failure
|
|
172
|
-
* included), the slot is gone and the client must re-mint to retry.
|
|
173
|
-
*/
|
|
174
|
-
async consume(ref: string): Promise<Uint8Array> {
|
|
175
|
-
const slot = this.slots.get(ref);
|
|
176
|
-
if (slot === undefined || this.now() > slot.expiresAtMs) {
|
|
177
|
-
throw new SlotUnknownError();
|
|
178
|
-
}
|
|
179
|
-
if (!slot.uploaded) {
|
|
180
|
-
throw new SlotEmptyError();
|
|
181
|
-
}
|
|
182
|
-
this.slots.delete(ref);
|
|
183
|
-
|
|
184
|
-
const filePath = this.stagePath(ref);
|
|
185
|
-
try {
|
|
186
|
-
const data = await fs.promises.readFile(filePath);
|
|
187
|
-
return data;
|
|
188
|
-
} catch (error) {
|
|
189
|
-
throw new Error(
|
|
190
|
-
`failed to read staged artifact: ${error instanceof Error ? error.message : String(error)}`,
|
|
191
|
-
);
|
|
192
|
-
} finally {
|
|
193
|
-
await fs.promises.rm(filePath, { force: true });
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Drops expired slots and their staged files. Called from mint, which
|
|
199
|
-
* bounds the registry: it can hold at most the slots minted within one
|
|
186
|
+
* Drops expired slots and their staged files. Called from reserve, which
|
|
187
|
+
* bounds the registry: it can hold at most the slots reserved within one
|
|
200
188
|
* TTL window.
|
|
201
189
|
*/
|
|
202
190
|
private sweep(): void {
|
|
203
191
|
const nowMs = this.now();
|
|
204
|
-
for (const [
|
|
192
|
+
for (const [key, slot] of this.slots) {
|
|
205
193
|
if (nowMs > slot.expiresAtMs) {
|
|
206
|
-
this.slots.delete(
|
|
207
|
-
fs.rmSync(this.stagePath(
|
|
194
|
+
this.slots.delete(key);
|
|
195
|
+
fs.rmSync(this.stagePath(key), { force: true });
|
|
208
196
|
}
|
|
209
197
|
}
|
|
210
198
|
}
|
|
211
199
|
|
|
212
200
|
/**
|
|
213
|
-
*
|
|
214
|
-
* driver's
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* with nothing to catch it.
|
|
218
|
-
*/
|
|
219
|
-
stagedFileName(ref: string): string {
|
|
220
|
-
return `${ref}.zip`;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Maps a reference to its staging file. refs are server-generated hex
|
|
225
|
-
* (never client-supplied paths), so simple joining is safe.
|
|
201
|
+
* Maps a staging key to its file: the key resolved under the driver's
|
|
202
|
+
* root, which is how the driver's download(key) will look for it. The
|
|
203
|
+
* key must carry the staging prefix and a plain hex file name — anything
|
|
204
|
+
* else is a caller bug, refused before it touches the filesystem.
|
|
226
205
|
*/
|
|
227
|
-
private stagePath(
|
|
228
|
-
|
|
206
|
+
private stagePath(key: string): string {
|
|
207
|
+
if (!key.startsWith(this.stagingPrefix)) {
|
|
208
|
+
throw new Error(`staging key ${key} is outside ${this.stagingPrefix}`);
|
|
209
|
+
}
|
|
210
|
+
const fileName = key.slice(this.stagingPrefix.length);
|
|
211
|
+
if (!STAGED_FILE_NAME.test(fileName)) {
|
|
212
|
+
throw new Error(`staging key ${key} does not name a staged archive`);
|
|
213
|
+
}
|
|
214
|
+
return path.join(this.root, key);
|
|
229
215
|
}
|
|
230
216
|
}
|
|
231
217
|
|
|
@@ -238,13 +224,16 @@ function limitBytes(
|
|
|
238
224
|
limit: number,
|
|
239
225
|
onBytes: (count: number) => void,
|
|
240
226
|
): (source: AsyncIterable<Buffer>) => AsyncIterable<Buffer> {
|
|
241
|
-
return async function* (
|
|
227
|
+
return async function* (
|
|
228
|
+
source: AsyncIterable<Buffer>,
|
|
229
|
+
): AsyncIterable<Buffer> {
|
|
242
230
|
let remaining = limit;
|
|
243
231
|
for await (const chunk of source) {
|
|
244
232
|
if (remaining <= 0) {
|
|
245
233
|
return;
|
|
246
234
|
}
|
|
247
|
-
const slice =
|
|
235
|
+
const slice =
|
|
236
|
+
chunk.length <= remaining ? chunk : chunk.subarray(0, remaining);
|
|
248
237
|
remaining -= slice.length;
|
|
249
238
|
onBytes(slice.length);
|
|
250
239
|
yield slice;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Archive staging — how an artifact above the gRPC message cap travels,
|
|
3
|
+
* on every blob driver, through one code path (#675, stigmer#1219).
|
|
4
|
+
*
|
|
5
|
+
* The domain owns the two identities and the mapping between them: the
|
|
6
|
+
* wire reference a client is handed (`sau_<hex>`, the capability the
|
|
7
|
+
* push presents) and the staging key the bytes land under in the blob
|
|
8
|
+
* store (`skills/staging/<hex>.zip`, the surface a sweep targets). A
|
|
9
|
+
* driver is asked only to sign or stage a PUT for a key it is given, to
|
|
10
|
+
* read the key back, and to delete it — so the local driver rides the
|
|
11
|
+
* transfer lane's slot registry and a bucket driver signs a PUT straight
|
|
12
|
+
* to the bucket, and nothing above this module can tell which.
|
|
13
|
+
*
|
|
14
|
+
* Consume reads then deletes: the delete is what keeps a reference
|
|
15
|
+
* single-use on a bucket, whose signed URL is otherwise repeatable within
|
|
16
|
+
* its TTL. The delete is best effort and loud — the bytes are already in
|
|
17
|
+
* hand, a replay of the same reference is a content-addressed no-op, and
|
|
18
|
+
* the bucket lifecycle rule on the staging prefix sweeps what the delete
|
|
19
|
+
* missed. On the local driver the same delete retires the staged file the
|
|
20
|
+
* slot registry would otherwise sweep at expiry.
|
|
21
|
+
*
|
|
22
|
+
* A reference the driver cannot read — malformed, never uploaded,
|
|
23
|
+
* expired, already consumed — is one condition on every driver
|
|
24
|
+
* ("unknown or expired"): a bucket cannot distinguish them, so neither
|
|
25
|
+
* does the wire.
|
|
26
|
+
*
|
|
27
|
+
* Proven by __tests__/staging.test.ts over a fake driver and over the
|
|
28
|
+
* real local driver with its slot registry; the conformance suite's
|
|
29
|
+
* transfer-lane block pins the wire behaviour on both editions.
|
|
30
|
+
*/
|
|
31
|
+
import { randomBytes } from "node:crypto";
|
|
32
|
+
|
|
33
|
+
import type { ArtifactStorage } from "../../../artifactstorage/artifact-storage.js";
|
|
34
|
+
import { ArtifactStorageNotFoundError } from "../../../artifactstorage/artifact-storage.js";
|
|
35
|
+
import type { Logger } from "../../../boot/logger.js";
|
|
36
|
+
import {
|
|
37
|
+
DEFAULT_SLOT_TTL_MS,
|
|
38
|
+
DOWNLOAD_URL_TTL_MS,
|
|
39
|
+
MAX_ZIP_SIZE,
|
|
40
|
+
REF_BYTE_LEN,
|
|
41
|
+
REF_PREFIX,
|
|
42
|
+
STAGING_KEY_PREFIX,
|
|
43
|
+
} from "../constants.js";
|
|
44
|
+
import { SlotUnknownError } from "./slots.js";
|
|
45
|
+
|
|
46
|
+
/** The hex the reference and the staging key share; 128 bits, lowercase. */
|
|
47
|
+
const HEX = new RegExp(`^[0-9a-f]{${REF_BYTE_LEN * 2}}$`);
|
|
48
|
+
const STAGED_ARCHIVE_SUFFIX = ".zip";
|
|
49
|
+
|
|
50
|
+
/** The shared hex of a well-formed reference or key, else undefined. */
|
|
51
|
+
function hexBetween(
|
|
52
|
+
value: string,
|
|
53
|
+
prefix: string,
|
|
54
|
+
suffix: string,
|
|
55
|
+
): string | undefined {
|
|
56
|
+
if (!value.startsWith(prefix) || !value.endsWith(suffix)) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const hex = value.slice(prefix.length, value.length - suffix.length);
|
|
60
|
+
return HEX.test(hex) ? hex : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A fresh upload reference: the prefix and 128 random bits, hex. */
|
|
64
|
+
export function newUploadRef(): string {
|
|
65
|
+
return REF_PREFIX + randomBytes(REF_BYTE_LEN).toString("hex");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The staging key a reference's bytes land under, or undefined when the
|
|
70
|
+
* reference is not one this server could have minted. Refs arrive from
|
|
71
|
+
* clients (the push request, the lane's URL path), so the shape is
|
|
72
|
+
* checked before it becomes a storage key.
|
|
73
|
+
*/
|
|
74
|
+
export function stagingKeyOf(ref: string): string | undefined {
|
|
75
|
+
const hex = hexBetween(ref, REF_PREFIX, "");
|
|
76
|
+
return hex === undefined
|
|
77
|
+
? undefined
|
|
78
|
+
: `${STAGING_KEY_PREFIX}${hex}${STAGED_ARCHIVE_SUFFIX}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The reference for a staging key this module minted. Keys reach here from
|
|
83
|
+
* the domain's own mint, never from a client, so a foreign shape is a
|
|
84
|
+
* programming error and throws.
|
|
85
|
+
*/
|
|
86
|
+
export function uploadRefOf(stagingKey: string): string {
|
|
87
|
+
const hex = hexBetween(stagingKey, STAGING_KEY_PREFIX, STAGED_ARCHIVE_SUFFIX);
|
|
88
|
+
if (hex === undefined) {
|
|
89
|
+
throw new Error(`${stagingKey} is not a staging key this domain minted`);
|
|
90
|
+
}
|
|
91
|
+
return REF_PREFIX + hex;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** What createArtifactUploadUrl hands the client. */
|
|
95
|
+
export interface StagedUpload {
|
|
96
|
+
/** The single-use reference the push presents. */
|
|
97
|
+
readonly ref: string;
|
|
98
|
+
/** Accepts one HTTP PUT of exactly the declared byte count. */
|
|
99
|
+
readonly url: string;
|
|
100
|
+
/** How long the reference and the URL stay valid. */
|
|
101
|
+
readonly ttlMs: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** What getArtifactDownloadUrl hands the client. */
|
|
105
|
+
export interface DownloadCapability {
|
|
106
|
+
readonly url: string;
|
|
107
|
+
/** The floor of the URL's validity (DOWNLOAD_URL_TTL_MS). */
|
|
108
|
+
readonly ttlMs: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The staging port both archive kinds (skills, plugins) push through. */
|
|
112
|
+
export interface ArchiveStaging {
|
|
113
|
+
/** Mints a reference and the URL its bytes are PUT to. */
|
|
114
|
+
mint(declaredSizeBytes: number): Promise<StagedUpload>;
|
|
115
|
+
/**
|
|
116
|
+
* Reads the staged bytes for a reference and retires it. Throws
|
|
117
|
+
* SlotUnknownError when the driver holds nothing under the reference;
|
|
118
|
+
* any other failure is the driver's and propagates as an infrastructure
|
|
119
|
+
* fault.
|
|
120
|
+
*/
|
|
121
|
+
consume(ref: string): Promise<Uint8Array>;
|
|
122
|
+
/** A time-limited download URL for a stored archive key. */
|
|
123
|
+
downloadUrl(storageKey: string): Promise<DownloadCapability>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function newArchiveStaging(
|
|
127
|
+
driver: ArtifactStorage,
|
|
128
|
+
logger: Logger,
|
|
129
|
+
): ArchiveStaging {
|
|
130
|
+
return {
|
|
131
|
+
async mint(declaredSizeBytes) {
|
|
132
|
+
// The controllers refuse over-limit declarations with the limit in
|
|
133
|
+
// the message before reaching here; this is the module's own
|
|
134
|
+
// invariant, so no driver is ever asked to stage more than the
|
|
135
|
+
// archive ceiling.
|
|
136
|
+
if (declaredSizeBytes <= 0 || declaredSizeBytes > MAX_ZIP_SIZE) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`declared size ${declaredSizeBytes} outside (0, ${MAX_ZIP_SIZE}]`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const ref = newUploadRef();
|
|
142
|
+
const key = stagingKeyOf(ref);
|
|
143
|
+
if (key === undefined) {
|
|
144
|
+
throw new Error(`minted reference ${ref} has no staging key`);
|
|
145
|
+
}
|
|
146
|
+
const presigned = await driver.presignPut(
|
|
147
|
+
key,
|
|
148
|
+
declaredSizeBytes,
|
|
149
|
+
DEFAULT_SLOT_TTL_MS,
|
|
150
|
+
);
|
|
151
|
+
return { ref, url: presigned.url, ttlMs: presigned.ttlMs };
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async consume(ref) {
|
|
155
|
+
const key = stagingKeyOf(ref);
|
|
156
|
+
if (key === undefined) {
|
|
157
|
+
throw new SlotUnknownError();
|
|
158
|
+
}
|
|
159
|
+
let data: Uint8Array;
|
|
160
|
+
try {
|
|
161
|
+
// The allocation is bounded by construction: a bucket accepts only
|
|
162
|
+
// the signed Content-Length, the slot registry only the reserved
|
|
163
|
+
// size, and both were checked against MAX_ZIP_SIZE at mint.
|
|
164
|
+
data = await driver.download(key);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error instanceof ArtifactStorageNotFoundError) {
|
|
167
|
+
throw new SlotUnknownError();
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
await driver.delete(key);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
logger.error("failed to delete consumed staged artifact", {
|
|
175
|
+
stagingKey: key,
|
|
176
|
+
error: error instanceof Error ? error.message : String(error),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return data;
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async downloadUrl(storageKey) {
|
|
183
|
+
const url = await driver.getSignedUrl(
|
|
184
|
+
storageKey,
|
|
185
|
+
DOWNLOAD_URL_TTL_MS,
|
|
186
|
+
"",
|
|
187
|
+
);
|
|
188
|
+
return { url, ttlMs: DOWNLOAD_URL_TTL_MS };
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -8,18 +8,22 @@
|
|
|
8
8
|
* are indistinguishable past this point.
|
|
9
9
|
*
|
|
10
10
|
* Skills carried this step alone (the Go ResolveArtifactSource port);
|
|
11
|
-
* plugins are pushed the same two ways over the same
|
|
12
|
-
* upload surface, as boot/compose.ts wires it), so the step lives
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* plugins are pushed the same two ways over the same staging port (one
|
|
12
|
+
* upload surface, as boot/compose.ts wires it), so the step lives here
|
|
13
|
+
* and each kind names its lane-absent sentence and the request fields it
|
|
14
|
+
* reads. The staging port (domain/skill/transfer/staging.ts) hides which
|
|
15
|
+
* blob driver holds the bytes: the step never sees a slot or a bucket.
|
|
15
16
|
*
|
|
16
17
|
* The unusable-reference arm is INVALID_ARGUMENT, not a server fault: an
|
|
17
|
-
* unknown, expired, already consumed, or
|
|
18
|
-
*
|
|
18
|
+
* unknown, expired, already consumed, or never-uploaded reference all mean
|
|
19
|
+
* the client must re-mint and re-upload. Any other failure reading the
|
|
20
|
+
* staged bytes is the driver's — an infrastructure fault the executor
|
|
21
|
+
* answers as a sanitized Internal, never dressed as the client's mistake.
|
|
19
22
|
*/
|
|
20
23
|
import type { DescMessage, MessageShape } from "@bufbuild/protobuf";
|
|
21
24
|
|
|
22
|
-
import
|
|
25
|
+
import { SlotUnknownError } from "../../domain/skill/transfer/slots.js";
|
|
26
|
+
import type { ArchiveStaging } from "../../domain/skill/transfer/staging.js";
|
|
23
27
|
import { failedPreconditionError, invalidArgumentError } from "../errors.js";
|
|
24
28
|
import type { PipelineStep } from "../pipeline.js";
|
|
25
29
|
import type { RequestContext } from "../request-context.js";
|
|
@@ -38,7 +42,7 @@ export interface ArtifactSourceBinding<Desc extends DescMessage> {
|
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
export function newResolveArtifactSourceStep<Desc extends DescMessage>(
|
|
41
|
-
|
|
45
|
+
staging: ArchiveStaging | undefined,
|
|
42
46
|
binding: ArtifactSourceBinding<Desc>,
|
|
43
47
|
): PipelineStep<Desc> {
|
|
44
48
|
return {
|
|
@@ -49,16 +53,19 @@ export function newResolveArtifactSourceStep<Desc extends DescMessage>(
|
|
|
49
53
|
ctx.set(ARTIFACT_BYTES_KEY, artifact);
|
|
50
54
|
return;
|
|
51
55
|
}
|
|
52
|
-
if (
|
|
56
|
+
if (staging === undefined) {
|
|
53
57
|
throw failedPreconditionError(binding.laneNotConfigured);
|
|
54
58
|
}
|
|
55
59
|
let data: Uint8Array;
|
|
56
60
|
try {
|
|
57
|
-
data = await
|
|
61
|
+
data = await staging.consume(artifactUploadRef);
|
|
58
62
|
} catch (error) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
if (error instanceof SlotUnknownError) {
|
|
64
|
+
throw invalidArgumentError(
|
|
65
|
+
`artifact_upload_ref not usable: ${error.message} — request a new upload URL via createArtifactUploadUrl`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
throw error;
|
|
62
69
|
}
|
|
63
70
|
ctx.set(ARTIFACT_BYTES_KEY, data);
|
|
64
71
|
},
|
|
@@ -38,7 +38,7 @@ beforeAll(async () => {
|
|
|
38
38
|
// test previously composed against the DEFAULT paths, which meant
|
|
39
39
|
// opening the developer's real ~/.stigmer/stigmer.db — and with the
|
|
40
40
|
// skill domain's boot-time staging wipe (#8) it would now also clear
|
|
41
|
-
// ~/.stigmer/storage/skills
|
|
41
|
+
// ~/.stigmer/storage/skills/staging. Tests never touch the home dir.
|
|
42
42
|
testDir = mkdtempSync(path.join(tmpdir(), "registry-lanes-test-"));
|
|
43
43
|
const config = loadConfig({
|
|
44
44
|
STIGMER_MODEL_REGISTRY_REFRESH: "off",
|