@boxd-sh/convex 0.1.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/LICENSE +21 -0
- package/README.md +264 -0
- package/dist/client/_generated/_ignore.d.ts +1 -0
- package/dist/client/_generated/_ignore.d.ts.map +1 -0
- package/dist/client/_generated/_ignore.js +3 -0
- package/dist/client/_generated/_ignore.js.map +1 -0
- package/dist/client/index.d.ts +327 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +126 -0
- package/dist/client/index.js.map +1 -0
- package/dist/component/_generated/api.d.ts +52 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +357 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +135 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +80 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/boxd.d.ts +35 -0
- package/dist/component/boxd.d.ts.map +1 -0
- package/dist/component/boxd.js +144 -0
- package/dist/component/boxd.js.map +1 -0
- package/dist/component/convex.config.d.ts +6 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +9 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/errors.d.ts +25 -0
- package/dist/component/errors.d.ts.map +1 -0
- package/dist/component/errors.js +75 -0
- package/dist/component/errors.js.map +1 -0
- package/dist/component/exec.d.ts +25 -0
- package/dist/component/exec.d.ts.map +1 -0
- package/dist/component/exec.js +100 -0
- package/dist/component/exec.js.map +1 -0
- package/dist/component/executions.d.ts +70 -0
- package/dist/component/executions.d.ts.map +1 -0
- package/dist/component/executions.js +106 -0
- package/dist/component/executions.js.map +1 -0
- package/dist/component/files.d.ts +44 -0
- package/dist/component/files.d.ts.map +1 -0
- package/dist/component/files.js +105 -0
- package/dist/component/files.js.map +1 -0
- package/dist/component/limits.d.ts +33 -0
- package/dist/component/limits.d.ts.map +1 -0
- package/dist/component/limits.js +44 -0
- package/dist/component/limits.js.map +1 -0
- package/dist/component/machines.d.ts +337 -0
- package/dist/component/machines.d.ts.map +1 -0
- package/dist/component/machines.js +411 -0
- package/dist/component/machines.js.map +1 -0
- package/dist/component/records.d.ts +87 -0
- package/dist/component/records.d.ts.map +1 -0
- package/dist/component/records.js +51 -0
- package/dist/component/records.js.map +1 -0
- package/dist/component/schema.d.ts +138 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +70 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/sessions.d.ts +21 -0
- package/dist/component/sessions.d.ts.map +1 -0
- package/dist/component/sessions.js +59 -0
- package/dist/component/sessions.js.map +1 -0
- package/dist/component/validate.d.ts +15 -0
- package/dist/component/validate.d.ts.map +1 -0
- package/dist/component/validate.js +44 -0
- package/dist/component/validate.js.map +1 -0
- package/package.json +99 -0
- package/src/client/_generated/_ignore.ts +1 -0
- package/src/client/index.ts +199 -0
- package/src/component/_generated/api.ts +68 -0
- package/src/component/_generated/component.ts +430 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +171 -0
- package/src/component/boxd.ts +188 -0
- package/src/component/convex.config.ts +9 -0
- package/src/component/errors.ts +105 -0
- package/src/component/exec.ts +134 -0
- package/src/component/executions.ts +115 -0
- package/src/component/files.ts +128 -0
- package/src/component/limits.ts +48 -0
- package/src/component/machines.ts +500 -0
- package/src/component/records.ts +71 -0
- package/src/component/schema.ts +77 -0
- package/src/component/sessions.ts +61 -0
- package/src/component/validate.ts +58 -0
- package/src/test.ts +28 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution records: reactive queries over the `executions` table, plus the
|
|
3
|
+
* bookkeeping mutations `exec.run` writes through.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { v } from "convex/values";
|
|
7
|
+
import { internal } from "./_generated/api.js";
|
|
8
|
+
import { internalMutation, query } from "./_generated/server.js";
|
|
9
|
+
import { clampLimit } from "./limits.js";
|
|
10
|
+
import { executionDoc } from "./records.js";
|
|
11
|
+
|
|
12
|
+
/** A machine's executions, newest first. Empty for another owner's machine. */
|
|
13
|
+
export const list = query({
|
|
14
|
+
args: {
|
|
15
|
+
machineId: v.string(),
|
|
16
|
+
ownerId: v.optional(v.string()),
|
|
17
|
+
limit: v.optional(v.number()),
|
|
18
|
+
},
|
|
19
|
+
returns: v.array(executionDoc),
|
|
20
|
+
handler: async (ctx, { machineId, ownerId, limit }) => {
|
|
21
|
+
const machine = await ctx.db
|
|
22
|
+
.query("machines")
|
|
23
|
+
.withIndex("by_machine_id", (q) => q.eq("machineId", machineId))
|
|
24
|
+
.unique();
|
|
25
|
+
if (!machine || machine.ownerId !== ownerId) return [];
|
|
26
|
+
return await ctx.db
|
|
27
|
+
.query("executions")
|
|
28
|
+
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
|
|
29
|
+
.order("desc")
|
|
30
|
+
.take(clampLimit(limit));
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const get = query({
|
|
35
|
+
args: { executionId: v.string(), ownerId: v.optional(v.string()) },
|
|
36
|
+
returns: v.union(v.null(), executionDoc),
|
|
37
|
+
handler: async (ctx, { executionId, ownerId }) => {
|
|
38
|
+
// Ids cross the component boundary as strings; a malformed one is simply
|
|
39
|
+
// not found.
|
|
40
|
+
const id = ctx.db.normalizeId("executions", executionId);
|
|
41
|
+
if (!id) return null;
|
|
42
|
+
const row = await ctx.db.get("executions", id);
|
|
43
|
+
return row && row.ownerId === ownerId ? row : null;
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Grace after the command's own timeout before the watchdog steps in. */
|
|
48
|
+
export const WATCHDOG_GRACE_MS = 60_000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Record a starting execution, and schedule a watchdog for it. The action
|
|
52
|
+
* marks the row finished itself, but an action that dies (a crash, a restart,
|
|
53
|
+
* running out of memory) never reaches its `catch`, and the row would stay
|
|
54
|
+
* "running" forever.
|
|
55
|
+
*/
|
|
56
|
+
export const begin = internalMutation({
|
|
57
|
+
args: {
|
|
58
|
+
machineId: v.string(),
|
|
59
|
+
ownerId: v.optional(v.string()),
|
|
60
|
+
command: v.string(),
|
|
61
|
+
cwd: v.optional(v.string()),
|
|
62
|
+
timeoutMs: v.number(),
|
|
63
|
+
},
|
|
64
|
+
returns: v.id("executions"),
|
|
65
|
+
handler: async (ctx, { timeoutMs, ...fields }) => {
|
|
66
|
+
const executionId = await ctx.db.insert("executions", {
|
|
67
|
+
...fields,
|
|
68
|
+
status: "running",
|
|
69
|
+
startedAt: Date.now(),
|
|
70
|
+
});
|
|
71
|
+
await ctx.scheduler.runAfter(
|
|
72
|
+
timeoutMs + WATCHDOG_GRACE_MS,
|
|
73
|
+
internal.executions.expire,
|
|
74
|
+
{ executionId },
|
|
75
|
+
);
|
|
76
|
+
return executionId;
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** Fail an execution whose action stopped before it could record a result. */
|
|
81
|
+
export const expire = internalMutation({
|
|
82
|
+
args: { executionId: v.id("executions") },
|
|
83
|
+
returns: v.null(),
|
|
84
|
+
handler: async (ctx, { executionId }) => {
|
|
85
|
+
const row = await ctx.db.get("executions", executionId);
|
|
86
|
+
if (row?.status === "running") {
|
|
87
|
+
await ctx.db.patch("executions", executionId, {
|
|
88
|
+
status: "failed",
|
|
89
|
+
error:
|
|
90
|
+
"the action running this command stopped before it recorded a result",
|
|
91
|
+
finishedAt: Date.now(),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
export const finish = internalMutation({
|
|
99
|
+
args: {
|
|
100
|
+
executionId: v.id("executions"),
|
|
101
|
+
status: v.union(v.literal("completed"), v.literal("failed")),
|
|
102
|
+
exitCode: v.optional(v.number()),
|
|
103
|
+
stdout: v.optional(v.string()),
|
|
104
|
+
stderr: v.optional(v.string()),
|
|
105
|
+
error: v.optional(v.string()),
|
|
106
|
+
},
|
|
107
|
+
returns: v.null(),
|
|
108
|
+
handler: async (ctx, { executionId, ...result }) => {
|
|
109
|
+
await ctx.db.patch("executions", executionId, {
|
|
110
|
+
...result,
|
|
111
|
+
finishedAt: Date.now(),
|
|
112
|
+
});
|
|
113
|
+
return null;
|
|
114
|
+
},
|
|
115
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files inside a machine. Whole files cross the function boundary, so each
|
|
3
|
+
* is capped well below Convex's 16 MiB argument and return limits. Move
|
|
4
|
+
* bigger files with `exec` (for example `curl` to or from storage).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { v } from "convex/values";
|
|
8
|
+
import { action, type ActionCtx } from "./_generated/server.js";
|
|
9
|
+
import { withBoxd } from "./boxd.js";
|
|
10
|
+
import { invalidArgument } from "./errors.js";
|
|
11
|
+
import { requireMachine } from "./records.js";
|
|
12
|
+
import { requireAbsolutePath } from "./validate.js";
|
|
13
|
+
|
|
14
|
+
/** Largest file `readFile` returns or `writeFile` accepts. */
|
|
15
|
+
export const MAX_FILE_BYTES = 8 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
const target = {
|
|
18
|
+
machineId: v.string(),
|
|
19
|
+
ownerId: v.optional(v.string()),
|
|
20
|
+
/** Absolute path inside the machine. */
|
|
21
|
+
path: v.string(),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function requireSize(bytes: number, path: string): void {
|
|
25
|
+
if (bytes > MAX_FILE_BYTES) {
|
|
26
|
+
throw invalidArgument(
|
|
27
|
+
`${path} is ${bytes} bytes, over the ${MAX_FILE_BYTES} byte limit. ` +
|
|
28
|
+
"Move large files with exec instead.",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function download(
|
|
34
|
+
ctx: ActionCtx,
|
|
35
|
+
args: { machineId: string; ownerId?: string; path: string },
|
|
36
|
+
): Promise<Uint8Array> {
|
|
37
|
+
requireAbsolutePath(args.path);
|
|
38
|
+
await requireMachine(ctx, args.machineId, args.ownerId);
|
|
39
|
+
const bytes = await withBoxd(ctx, (boxd) =>
|
|
40
|
+
boxd.machines.files.download(args.machineId, args.path),
|
|
41
|
+
);
|
|
42
|
+
requireSize(bytes.byteLength, args.path);
|
|
43
|
+
return bytes;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Read a file as UTF-8 text. A file that isn't valid UTF-8 is refused. */
|
|
47
|
+
export const readFile = action({
|
|
48
|
+
args: target,
|
|
49
|
+
returns: v.string(),
|
|
50
|
+
handler: async (ctx, args) => {
|
|
51
|
+
const bytes = await download(ctx, args);
|
|
52
|
+
try {
|
|
53
|
+
// Strict: a lenient decode would turn each bad byte into a 3-byte
|
|
54
|
+
// U+FFFD, and could push a binary file past the return limit.
|
|
55
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
56
|
+
} catch {
|
|
57
|
+
throw invalidArgument(
|
|
58
|
+
`${args.path} is not UTF-8 text. Read it with readFileBytes.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** Read a file byte for byte. */
|
|
65
|
+
export const readFileBytes = action({
|
|
66
|
+
args: target,
|
|
67
|
+
returns: v.bytes(),
|
|
68
|
+
handler: async (ctx, args) => {
|
|
69
|
+
const bytes = await download(ctx, args);
|
|
70
|
+
// A standalone copy: `bytes` can be a view into a larger buffer.
|
|
71
|
+
return bytes.slice().buffer;
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/** Create or replace a file. The parent directory must exist. */
|
|
76
|
+
export const writeFile = action({
|
|
77
|
+
args: { ...target, content: v.union(v.string(), v.bytes()) },
|
|
78
|
+
returns: v.object({ bytesWritten: v.number() }),
|
|
79
|
+
handler: async (ctx, { machineId, ownerId, path, content }) => {
|
|
80
|
+
requireAbsolutePath(path);
|
|
81
|
+
const data =
|
|
82
|
+
typeof content === "string"
|
|
83
|
+
? new TextEncoder().encode(content)
|
|
84
|
+
: new Uint8Array(content);
|
|
85
|
+
requireSize(data.byteLength, path);
|
|
86
|
+
await requireMachine(ctx, machineId, ownerId);
|
|
87
|
+
const bytesWritten = await withBoxd(ctx, (boxd) =>
|
|
88
|
+
boxd.machines.files.upload(machineId, path, data),
|
|
89
|
+
);
|
|
90
|
+
return { bytesWritten };
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/** The immediate contents of a directory. */
|
|
95
|
+
export const listDir = action({
|
|
96
|
+
args: target,
|
|
97
|
+
returns: v.object({
|
|
98
|
+
entries: v.array(
|
|
99
|
+
v.object({
|
|
100
|
+
name: v.string(),
|
|
101
|
+
isDir: v.boolean(),
|
|
102
|
+
sizeBytes: v.number(),
|
|
103
|
+
permissions: v.string(),
|
|
104
|
+
/** Epoch milliseconds. */
|
|
105
|
+
modifiedAt: v.number(),
|
|
106
|
+
}),
|
|
107
|
+
),
|
|
108
|
+
/** The directory held more entries than boxd returns in one listing. */
|
|
109
|
+
truncated: v.boolean(),
|
|
110
|
+
}),
|
|
111
|
+
handler: async (ctx, { machineId, ownerId, path }) => {
|
|
112
|
+
requireAbsolutePath(path);
|
|
113
|
+
await requireMachine(ctx, machineId, ownerId);
|
|
114
|
+
const listing = await withBoxd(ctx, (boxd) =>
|
|
115
|
+
boxd.machines.files.listDir(machineId, path),
|
|
116
|
+
);
|
|
117
|
+
return {
|
|
118
|
+
entries: listing.entries.map((entry) => ({
|
|
119
|
+
name: entry.name,
|
|
120
|
+
isDir: entry.isDir,
|
|
121
|
+
sizeBytes: entry.sizeBytes,
|
|
122
|
+
permissions: entry.permissions,
|
|
123
|
+
modifiedAt: entry.modifiedAt.getTime(),
|
|
124
|
+
})),
|
|
125
|
+
truncated: listing.truncated,
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounds that keep every value this component stores or returns inside
|
|
3
|
+
* Convex's limits: 1 MiB per document, 16 MiB per function return value,
|
|
4
|
+
* and a 10 minute ceiling on an action.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Stored per stream on an execution row: two of these stay far below 1 MiB. */
|
|
8
|
+
export const MAX_STORED_OUTPUT = 64_000;
|
|
9
|
+
/** Stored command line on an execution row. */
|
|
10
|
+
export const MAX_STORED_COMMAND = 4_000;
|
|
11
|
+
/** Stored error message on any row. */
|
|
12
|
+
export const MAX_STORED_ERROR = 4_000;
|
|
13
|
+
/**
|
|
14
|
+
* Returned per stream from `exec`. A character is at most 3 UTF-8 bytes, so
|
|
15
|
+
* two streams at this cap stay under the 16 MiB return limit.
|
|
16
|
+
*/
|
|
17
|
+
export const MAX_RETURNED_OUTPUT = 2_000_000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A command that outlives its action would leave its execution row stuck at
|
|
21
|
+
* "running", because the action's `catch` never runs. So the remote command
|
|
22
|
+
* is always bounded below the 10 minute action ceiling.
|
|
23
|
+
*/
|
|
24
|
+
export const DEFAULT_EXEC_TIMEOUT_MS = 540_000;
|
|
25
|
+
export const MAX_EXEC_TIMEOUT_MS = 570_000;
|
|
26
|
+
|
|
27
|
+
/** How long `create` and `fork` wait for the machine to accept an exec. */
|
|
28
|
+
export const DEFAULT_READY_TIMEOUT_MS = 120_000;
|
|
29
|
+
export const MAX_READY_TIMEOUT_MS = 480_000;
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_LIST_LIMIT = 100;
|
|
32
|
+
export const MAX_LIST_LIMIT = 500;
|
|
33
|
+
|
|
34
|
+
const TRUNCATION_MARKER = "\n…[truncated]";
|
|
35
|
+
|
|
36
|
+
/** Truncate to at most `max` characters, the marker included. */
|
|
37
|
+
export function truncate(text: string, max: number): string {
|
|
38
|
+
if (text.length <= max) return text;
|
|
39
|
+
const keep = Math.max(0, max - TRUNCATION_MARKER.length);
|
|
40
|
+
return `${text.slice(0, keep)}${TRUNCATION_MARKER}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A caller-supplied page size, forced into `[1, MAX_LIST_LIMIT]`. */
|
|
44
|
+
export function clampLimit(limit: number | undefined): number {
|
|
45
|
+
const value = Math.floor(limit ?? DEFAULT_LIST_LIMIT);
|
|
46
|
+
if (!Number.isFinite(value)) return DEFAULT_LIST_LIMIT;
|
|
47
|
+
return Math.min(Math.max(value, 1), MAX_LIST_LIMIT);
|
|
48
|
+
}
|