@langchain/langgraph-cli 1.3.1 → 1.4.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/cli/cli.mjs +1 -0
- package/dist/cli/deploy.mjs +1199 -0
- package/dist/cli/utils/archive.mjs +130 -0
- package/dist/cli/utils/deploy-helpers.mjs +396 -0
- package/dist/cli/utils/deploy-ignore.mjs +163 -0
- package/dist/cli/utils/deploy-output.mjs +387 -0
- package/dist/cli/utils/host-backend.mjs +334 -0
- package/dist/docker/docker.mjs +19 -8
- package/package.json +4 -3
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create a tarball of project source for remote builds, ported from the
|
|
3
|
+
* Python CLI's `archive.py`.
|
|
4
|
+
*
|
|
5
|
+
* The JS `assembleLocalDeps` requires every local dependency to live inside
|
|
6
|
+
* the config directory, so (unlike the Python CLI) there are no external
|
|
7
|
+
* build contexts to splice in: the archive root is always the config's parent
|
|
8
|
+
* directory and the config path is included at its natural relative location.
|
|
9
|
+
*/
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import { promises as fs } from "node:fs";
|
|
13
|
+
import { create as tarCreate } from "tar";
|
|
14
|
+
import { ALWAYS_EXCLUDE_NAMES, buildIgnoreSpec } from "./deploy-ignore.mjs";
|
|
15
|
+
/** Number of bytes in one mebibyte, used for human-readable size output. */
|
|
16
|
+
const BYTES_PER_MIB = 1_048_576;
|
|
17
|
+
/** Archive size above which a warning is emitted (50 MB). */
|
|
18
|
+
const WARN_SIZE = 50 * 1024 * 1024;
|
|
19
|
+
/** Hard archive size limit enforced by the host backend (200 MB). */
|
|
20
|
+
const MAX_SIZE = 200 * 1024 * 1024;
|
|
21
|
+
/**
|
|
22
|
+
* Recursively collect the POSIX-relative paths of every file under `rootDir`
|
|
23
|
+
* that should be included in the archive.
|
|
24
|
+
*
|
|
25
|
+
* @remarks
|
|
26
|
+
* Symlinks (and other non-regular entries) are skipped. Always-excluded
|
|
27
|
+
* directories are pruned by name; other ignored directories are pruned too,
|
|
28
|
+
* unless a negation (`!pattern`) could re-include one of their descendants (see
|
|
29
|
+
* {@link IgnoreSpec.requiresDirWalk}), in which case the directory is still
|
|
30
|
+
* walked and filtering happens per-file.
|
|
31
|
+
*
|
|
32
|
+
* @param rootDir - Absolute directory to walk (the archive root).
|
|
33
|
+
* @returns Sorted-by-walk list of relative file paths to include.
|
|
34
|
+
*/
|
|
35
|
+
async function collectFiles(rootDir) {
|
|
36
|
+
const spec = await buildIgnoreSpec(rootDir, { includeGitignore: true });
|
|
37
|
+
const files = [];
|
|
38
|
+
async function walk(dir, relPrefix) {
|
|
39
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
|
|
42
|
+
const abs = path.join(dir, entry.name);
|
|
43
|
+
// Strip symlinks (and other non-regular entries) for safety, mirroring
|
|
44
|
+
// the Python `_tar_filter`.
|
|
45
|
+
if (entry.isSymbolicLink())
|
|
46
|
+
continue;
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
if (ALWAYS_EXCLUDE_NAMES.has(entry.name))
|
|
49
|
+
continue;
|
|
50
|
+
const dirRel = `${rel}/`;
|
|
51
|
+
// Prune ignored directories, but still descend into the (usually
|
|
52
|
+
// small) set a negation could re-include from, instead of walking
|
|
53
|
+
// every ignored directory whenever any negation exists.
|
|
54
|
+
if (spec.ignores(dirRel) && !spec.requiresDirWalk(rel))
|
|
55
|
+
continue;
|
|
56
|
+
await walk(abs, rel);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!entry.isFile())
|
|
60
|
+
continue;
|
|
61
|
+
if (spec.ignores(rel))
|
|
62
|
+
continue;
|
|
63
|
+
files.push(rel);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
await walk(rootDir, "");
|
|
67
|
+
return files;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Create a `source.tar.gz` of the project rooted at the config's parent
|
|
71
|
+
* directory.
|
|
72
|
+
*
|
|
73
|
+
* @remarks
|
|
74
|
+
* Source files are filtered through {@link buildIgnoreSpec} (honoring
|
|
75
|
+
* `.dockerignore` and `.gitignore`). The archive is validated to contain the
|
|
76
|
+
* config file and checked against the size warning/limit thresholds. The
|
|
77
|
+
* caller is responsible for invoking {@link Archive.cleanup} when done; on
|
|
78
|
+
* failure the temp directory is cleaned up automatically.
|
|
79
|
+
*
|
|
80
|
+
* @param configPath - Path to `langgraph.json` (its parent is the archive root).
|
|
81
|
+
* @param options - Behavior options.
|
|
82
|
+
* @param options.onWarn - Invoked with a warning message when the archive
|
|
83
|
+
* exceeds {@link WARN_SIZE} but is still under the hard limit.
|
|
84
|
+
* @returns The created {@link Archive}.
|
|
85
|
+
* @throws Error if the config file is missing from the archive or the archive
|
|
86
|
+
* exceeds the 200 MB limit.
|
|
87
|
+
*/
|
|
88
|
+
export async function createArchive(configPath, options = {}) {
|
|
89
|
+
const resolvedConfig = path.resolve(configPath);
|
|
90
|
+
const contextDir = path.dirname(resolvedConfig);
|
|
91
|
+
const configRel = path
|
|
92
|
+
.relative(contextDir, resolvedConfig)
|
|
93
|
+
.split(path.sep)
|
|
94
|
+
.join("/");
|
|
95
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "langgraph-deploy-"));
|
|
96
|
+
const archivePath = path.join(tmpDir, "source.tar.gz");
|
|
97
|
+
const cleanup = async () => {
|
|
98
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
99
|
+
};
|
|
100
|
+
try {
|
|
101
|
+
const files = await collectFiles(contextDir);
|
|
102
|
+
if (!files.includes(configRel)) {
|
|
103
|
+
throw new Error(`Archive validation failed: ${configRel} not found in archive`);
|
|
104
|
+
}
|
|
105
|
+
await tarCreate({
|
|
106
|
+
gzip: true,
|
|
107
|
+
file: archivePath,
|
|
108
|
+
cwd: contextDir,
|
|
109
|
+
portable: true,
|
|
110
|
+
follow: false,
|
|
111
|
+
noDirRecurse: true,
|
|
112
|
+
}, files);
|
|
113
|
+
const { size: fileSize } = await fs.stat(archivePath);
|
|
114
|
+
if (fileSize > MAX_SIZE) {
|
|
115
|
+
throw new Error(`Source archive is ${(fileSize / BYTES_PER_MIB).toFixed(1)} MB, which ` +
|
|
116
|
+
"exceeds the 200 MB limit. Add large files to .dockerignore or " +
|
|
117
|
+
".gitignore (model weights, data sets, etc.).");
|
|
118
|
+
}
|
|
119
|
+
if (fileSize > WARN_SIZE && options.onWarn) {
|
|
120
|
+
options.onWarn(`Warning: source archive is ${(fileSize / BYTES_PER_MIB).toFixed(1)} MB. ` +
|
|
121
|
+
"Consider adding large files to .dockerignore or .gitignore.");
|
|
122
|
+
}
|
|
123
|
+
return { archivePath, fileSize, configRel, cleanup };
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
await cleanup();
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export { BYTES_PER_MIB };
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the `deploy` command, ported from `deploy.py`. Kept free of
|
|
3
|
+
* I/O so they can be unit-tested in isolation.
|
|
4
|
+
*/
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
/**
|
|
7
|
+
* Env vars that must never be forwarded as deployment secrets. Mirrors
|
|
8
|
+
* `RESERVED_ENV_VARS` in the Python CLI (LANGCHAIN_RESERVED_ENV_VARS plus
|
|
9
|
+
* ALLOWED_SELF_HOSTED_ENV_VARS).
|
|
10
|
+
*/
|
|
11
|
+
export const RESERVED_ENV_VARS = new Set([
|
|
12
|
+
"LANGCHAIN_TRACING_V2",
|
|
13
|
+
"LANGSMITH_TRACING_V2",
|
|
14
|
+
"LANGCHAIN_ENDPOINT",
|
|
15
|
+
"LANGCHAIN_PROJECT",
|
|
16
|
+
"LANGSMITH_PROJECT",
|
|
17
|
+
"LANGSMITH_LANGGRAPH_GIT_REPO",
|
|
18
|
+
"LANGGRAPH_GIT_REPO_PATH",
|
|
19
|
+
"LANGCHAIN_API_KEY",
|
|
20
|
+
"LANGGRAPH_HOST_API_KEY",
|
|
21
|
+
"LANGSMITH_CONTROL_PLANE_API_KEY",
|
|
22
|
+
"POSTGRES_URI",
|
|
23
|
+
"POSTGRES_PASSWORD",
|
|
24
|
+
"DATABASE_URI",
|
|
25
|
+
"LANGSMITH_LANGGRAPH_GIT_REF",
|
|
26
|
+
"LANGSMITH_LANGGRAPH_GIT_REF_SHA",
|
|
27
|
+
"LANGGRAPH_AUTH_TYPE",
|
|
28
|
+
"LANGSMITH_AUTH_ENDPOINT",
|
|
29
|
+
"LANGSMITH_TENANT_ID",
|
|
30
|
+
"LANGSMITH_AUTH_VERIFY_TENANT_ID",
|
|
31
|
+
"LANGSMITH_HOST_PROJECT_ID",
|
|
32
|
+
"LANGSMITH_HOST_PROJECT_NAME",
|
|
33
|
+
"LANGSMITH_HOST_REVISION_ID",
|
|
34
|
+
"LOG_JSON",
|
|
35
|
+
"LOG_DICT_TRACEBACKS",
|
|
36
|
+
"REDIS_URI",
|
|
37
|
+
"LANGCHAIN_CALLBACKS_BACKGROUND",
|
|
38
|
+
"DD_TRACE_PSYCOPG_ENABLED",
|
|
39
|
+
"DD_TRACE_REDIS_ENABLED",
|
|
40
|
+
"LANGSMITH_DEPLOYMENT_NAME",
|
|
41
|
+
"LANGGRAPH_CLOUD_LICENSE_KEY",
|
|
42
|
+
"LANGSMITH_API_KEY",
|
|
43
|
+
"LANGSMITH_ENDPOINT",
|
|
44
|
+
"POSTGRES_URI_CUSTOM",
|
|
45
|
+
"REDIS_URI_CUSTOM",
|
|
46
|
+
"PATH",
|
|
47
|
+
"PORT",
|
|
48
|
+
"MOUNT_PREFIX",
|
|
49
|
+
"LSD_ENV",
|
|
50
|
+
"LSD_DD_API_KEY",
|
|
51
|
+
"LSD_DD_ENDPOINT",
|
|
52
|
+
"LSD_DEPLOYMENT_TYPE",
|
|
53
|
+
]);
|
|
54
|
+
/** Environment variable names checked, in order, when resolving the API key. */
|
|
55
|
+
export const API_KEY_ENV_NAMES = [
|
|
56
|
+
"LANGGRAPH_HOST_API_KEY",
|
|
57
|
+
"LANGSMITH_API_KEY",
|
|
58
|
+
"LANGCHAIN_API_KEY",
|
|
59
|
+
];
|
|
60
|
+
/** Env var holding a default deployment name. */
|
|
61
|
+
export const DEPLOYMENT_NAME_ENV = "LANGSMITH_DEPLOYMENT_NAME";
|
|
62
|
+
/**
|
|
63
|
+
* Whether `child` resolves to a location strictly inside `parent`.
|
|
64
|
+
*
|
|
65
|
+
* @remarks
|
|
66
|
+
* Used to keep configuration-supplied paths (e.g. the `env` file referenced
|
|
67
|
+
* from `langgraph.json`) contained within the project directory, preventing
|
|
68
|
+
* traversal (`../`) or absolute paths from reaching files outside the project.
|
|
69
|
+
* Both arguments are expected to be absolute paths.
|
|
70
|
+
*
|
|
71
|
+
* @param parent - Absolute path to the directory that must contain `child`.
|
|
72
|
+
* @param child - Absolute path to validate.
|
|
73
|
+
* @returns `true` when `child` is strictly inside `parent`.
|
|
74
|
+
*/
|
|
75
|
+
export function isPathWithin(parent, child) {
|
|
76
|
+
const rel = path.relative(parent, child);
|
|
77
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
78
|
+
}
|
|
79
|
+
/** Default host backend API URL. */
|
|
80
|
+
export const DEFAULT_HOST_URL = "https://api.host.langchain.com";
|
|
81
|
+
/** Revision statuses that end status polling (success or failure). */
|
|
82
|
+
export const TERMINAL_STATUSES = new Set([
|
|
83
|
+
"DEPLOYED",
|
|
84
|
+
"CREATE_FAILED",
|
|
85
|
+
"BUILD_FAILED",
|
|
86
|
+
"DEPLOY_FAILED",
|
|
87
|
+
"SKIPPED",
|
|
88
|
+
]);
|
|
89
|
+
/** Characters disallowed in custom install/build commands. */
|
|
90
|
+
const DISALLOWED_BUILD_COMMAND_CHARS = [
|
|
91
|
+
'"',
|
|
92
|
+
"`",
|
|
93
|
+
"\\",
|
|
94
|
+
"\n",
|
|
95
|
+
"\r",
|
|
96
|
+
"\0",
|
|
97
|
+
"\t",
|
|
98
|
+
"|",
|
|
99
|
+
";",
|
|
100
|
+
"$",
|
|
101
|
+
">",
|
|
102
|
+
"<",
|
|
103
|
+
];
|
|
104
|
+
// Matches a single "&" that is NOT part of "&&" (blocks background execution
|
|
105
|
+
// while allowing `cmd1 && cmd2`).
|
|
106
|
+
const SINGLE_AMPERSAND_RE = /(?<!&)&(?:&&)*(?!&)/;
|
|
107
|
+
/**
|
|
108
|
+
* Whether a custom install/build command contains disallowed shell content.
|
|
109
|
+
*
|
|
110
|
+
* @remarks
|
|
111
|
+
* Rejects shell metacharacters (see {@link DISALLOWED_BUILD_COMMAND_CHARS}) and
|
|
112
|
+
* a lone `&` (background execution), while still allowing `cmd1 && cmd2`.
|
|
113
|
+
*
|
|
114
|
+
* @param command - The command string to validate.
|
|
115
|
+
* @returns `true` if the command contains disallowed characters or patterns.
|
|
116
|
+
*/
|
|
117
|
+
export function hasDisallowedBuildCommandContent(command) {
|
|
118
|
+
if (DISALLOWED_BUILD_COMMAND_CHARS.some((char) => command.includes(char))) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
return SINGLE_AMPERSAND_RE.test(command);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Sanitize a deployment/directory name into a valid LangSmith Deployment name.
|
|
125
|
+
*
|
|
126
|
+
* @remarks
|
|
127
|
+
* Deployment names allow only lowercase `[a-z0-9-]`; invalid characters become
|
|
128
|
+
* hyphens and leading/trailing hyphens are trimmed.
|
|
129
|
+
*
|
|
130
|
+
* @param value - The raw name (e.g. a directory name), may be nullish.
|
|
131
|
+
* @returns A normalized name, or `"app"` when empty/fully invalid.
|
|
132
|
+
*/
|
|
133
|
+
export function normalizeName(value) {
|
|
134
|
+
if (!value)
|
|
135
|
+
return "app";
|
|
136
|
+
const slug = value
|
|
137
|
+
.toLowerCase()
|
|
138
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
139
|
+
.replace(/^-+|-+$/g, "");
|
|
140
|
+
return slug || "app";
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Validate a Docker image tag (`[A-Za-z0-9_.-]`), defaulting to `latest`.
|
|
144
|
+
*
|
|
145
|
+
* @param value - The requested tag (empty becomes `latest`).
|
|
146
|
+
* @returns The validated tag.
|
|
147
|
+
* @throws Error if the tag contains characters outside `[A-Za-z0-9_.-]`.
|
|
148
|
+
*/
|
|
149
|
+
export function normalizeImageTag(value) {
|
|
150
|
+
const tag = value || "latest";
|
|
151
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(tag)) {
|
|
152
|
+
throw new Error("Image tag may only contain characters A-Z, a-z, 0-9, '_', '-', '.'");
|
|
153
|
+
}
|
|
154
|
+
return tag;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Convert an env dict to a deployment secrets list, dropping reserved and
|
|
158
|
+
* empty-valued variables.
|
|
159
|
+
*
|
|
160
|
+
* @param envVars - Environment variables to convert.
|
|
161
|
+
* @param onSkip - Optional callback invoked with the name of each reserved
|
|
162
|
+
* variable that is skipped.
|
|
163
|
+
* @returns The list of forwardable secrets.
|
|
164
|
+
*/
|
|
165
|
+
export function secretsFromEnv(envVars, onSkip) {
|
|
166
|
+
const secrets = [];
|
|
167
|
+
for (const [name, value] of Object.entries(envVars)) {
|
|
168
|
+
if (RESERVED_ENV_VARS.has(name)) {
|
|
169
|
+
onSkip?.(name);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (!value)
|
|
173
|
+
continue;
|
|
174
|
+
secrets.push({ name, value });
|
|
175
|
+
}
|
|
176
|
+
return secrets;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Return a copy of env vars without the deployment-name key (so it is not
|
|
180
|
+
* forwarded as a secret).
|
|
181
|
+
*
|
|
182
|
+
* @param envVars - Environment variables to copy.
|
|
183
|
+
* @returns A new object without {@link DEPLOYMENT_NAME_ENV}.
|
|
184
|
+
*/
|
|
185
|
+
export function envWithoutDeploymentName(envVars) {
|
|
186
|
+
const filtered = { ...envVars };
|
|
187
|
+
delete filtered[DEPLOYMENT_NAME_ENV];
|
|
188
|
+
return filtered;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Extract a deployment's public URL from its `source_config.custom_url`.
|
|
192
|
+
*
|
|
193
|
+
* @param deployment - The deployment object.
|
|
194
|
+
* @returns The custom URL, or `"-"` when absent.
|
|
195
|
+
*/
|
|
196
|
+
function extractDeploymentUrl(deployment) {
|
|
197
|
+
const sourceConfig = deployment.source_config;
|
|
198
|
+
if (sourceConfig && typeof sourceConfig === "object") {
|
|
199
|
+
const customUrl = sourceConfig.custom_url;
|
|
200
|
+
if (typeof customUrl === "string" && customUrl)
|
|
201
|
+
return customUrl;
|
|
202
|
+
}
|
|
203
|
+
return "-";
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Render a single table row, left-padding each cell to its column width.
|
|
207
|
+
*
|
|
208
|
+
* @param row - Cell values for the row.
|
|
209
|
+
* @param widths - Column widths (parallel to `row`).
|
|
210
|
+
* @returns The formatted, two-space-separated row string.
|
|
211
|
+
*/
|
|
212
|
+
function formatRow(row, widths) {
|
|
213
|
+
return row.map((value, index) => value.padEnd(widths[index])).join(" ");
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Render a fixed-width text table with a header and separator row.
|
|
217
|
+
*
|
|
218
|
+
* @param headers - Column header labels.
|
|
219
|
+
* @param rows - Row data (each parallel to `headers`).
|
|
220
|
+
* @returns The full table as a newline-joined string.
|
|
221
|
+
*/
|
|
222
|
+
function buildTable(headers, rows) {
|
|
223
|
+
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index].length)));
|
|
224
|
+
const lines = [
|
|
225
|
+
formatRow(headers, widths),
|
|
226
|
+
formatRow(widths.map((width) => "-".repeat(width)), widths),
|
|
227
|
+
];
|
|
228
|
+
for (const row of rows)
|
|
229
|
+
lines.push(formatRow(row, widths));
|
|
230
|
+
return lines.join("\n");
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Render a table of deployments with ID, name, and URL columns.
|
|
234
|
+
*
|
|
235
|
+
* @param deployments - Deployment objects to display.
|
|
236
|
+
* @returns The formatted table string.
|
|
237
|
+
*/
|
|
238
|
+
export function formatDeploymentsTable(deployments) {
|
|
239
|
+
const rows = deployments.map((deployment) => [
|
|
240
|
+
String(deployment.id ?? "-") || "-",
|
|
241
|
+
String(deployment.name ?? "-") || "-",
|
|
242
|
+
extractDeploymentUrl(deployment),
|
|
243
|
+
]);
|
|
244
|
+
return buildTable(["Deployment ID", "Deployment Name", "Deployment URL"], rows);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Render a table of revisions with ID, status, and creation time.
|
|
248
|
+
*
|
|
249
|
+
* @remarks
|
|
250
|
+
* Since revisions are listed newest-first, only the first `DEPLOYED` revision
|
|
251
|
+
* keeps that status; subsequent `DEPLOYED` rows are relabeled `REPLACED`.
|
|
252
|
+
*
|
|
253
|
+
* @param revisions - Revision objects to display (newest first).
|
|
254
|
+
* @returns The formatted table string.
|
|
255
|
+
*/
|
|
256
|
+
export function formatRevisionsTable(revisions) {
|
|
257
|
+
let latestDeployedSeen = false;
|
|
258
|
+
const rows = revisions.map((revision) => {
|
|
259
|
+
let status = String(revision.status ?? "-") || "-";
|
|
260
|
+
if (status === "DEPLOYED") {
|
|
261
|
+
if (latestDeployedSeen) {
|
|
262
|
+
status = "REPLACED";
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
latestDeployedSeen = true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return [
|
|
269
|
+
String(revision.id ?? "-") || "-",
|
|
270
|
+
status,
|
|
271
|
+
String(revision.created_at ?? "-") || "-",
|
|
272
|
+
];
|
|
273
|
+
});
|
|
274
|
+
return buildTable(["Revision ID", "Status", "Created At"], rows);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Convert a timestamp to a readable UTC string (`YYYY-MM-DD HH:MM:SS`).
|
|
278
|
+
*
|
|
279
|
+
* @param ts - Epoch milliseconds (number) or a pre-formatted value.
|
|
280
|
+
* @returns The formatted timestamp, or an empty string for falsy input.
|
|
281
|
+
*/
|
|
282
|
+
export function formatTimestamp(ts) {
|
|
283
|
+
if (typeof ts === "number") {
|
|
284
|
+
const date = new Date(ts);
|
|
285
|
+
return date.toISOString().slice(0, 19).replace("T", " ");
|
|
286
|
+
}
|
|
287
|
+
return ts ? String(ts) : "";
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Format a single log entry as `[timestamp] [level] message`, omitting any
|
|
291
|
+
* missing parts.
|
|
292
|
+
*
|
|
293
|
+
* @param entry - A log entry with optional `timestamp`, `level`, `message`.
|
|
294
|
+
* @returns The formatted log line.
|
|
295
|
+
*/
|
|
296
|
+
export function formatLogEntry(entry) {
|
|
297
|
+
const ts = formatTimestamp(entry.timestamp ?? "");
|
|
298
|
+
const level = entry.level ?? "";
|
|
299
|
+
const message = entry.message ?? "";
|
|
300
|
+
if (ts && level)
|
|
301
|
+
return `[${ts}] [${level}] ${message}`;
|
|
302
|
+
if (ts)
|
|
303
|
+
return `[${ts}] ${message}`;
|
|
304
|
+
return String(message);
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Map a log level to a display color.
|
|
308
|
+
*
|
|
309
|
+
* @param level - The log level (case-insensitive).
|
|
310
|
+
* @returns `"red"` for errors, `"yellow"` for warnings, otherwise `undefined`.
|
|
311
|
+
*/
|
|
312
|
+
export function levelColor(level) {
|
|
313
|
+
const upper = typeof level === "string" ? level.toUpperCase() : "";
|
|
314
|
+
if (upper === "ERROR" || upper === "CRITICAL")
|
|
315
|
+
return "red";
|
|
316
|
+
if (upper === "WARNING")
|
|
317
|
+
return "yellow";
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Derive the LangSmith dashboard base URL from the API host URL.
|
|
322
|
+
*
|
|
323
|
+
* @remarks
|
|
324
|
+
* Maps the prod host to `smith.langchain.com`, regional hosts (e.g.
|
|
325
|
+
* `eu.api.host.langchain.com`) to their regional dashboard, passes through
|
|
326
|
+
* localhost, and defaults to the prod dashboard for anything unrecognized.
|
|
327
|
+
*
|
|
328
|
+
* @param hostUrl - The host backend API URL, may be nullish.
|
|
329
|
+
* @returns The dashboard base URL (no trailing slash).
|
|
330
|
+
*/
|
|
331
|
+
export function smithDashboardBaseUrl(hostUrl) {
|
|
332
|
+
if (!hostUrl)
|
|
333
|
+
return "https://smith.langchain.com";
|
|
334
|
+
let parsed;
|
|
335
|
+
try {
|
|
336
|
+
parsed = new URL(hostUrl);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return "https://smith.langchain.com";
|
|
340
|
+
}
|
|
341
|
+
const hostname = parsed.hostname || "";
|
|
342
|
+
if (hostname === "localhost" || hostname === "127.0.0.1") {
|
|
343
|
+
return hostUrl.replace(/\/+$/, "");
|
|
344
|
+
}
|
|
345
|
+
const apiHostSuffix = "api.host.langchain.com";
|
|
346
|
+
if (hostname === apiHostSuffix)
|
|
347
|
+
return "https://smith.langchain.com";
|
|
348
|
+
if (hostname.endsWith(`.${apiHostSuffix}`)) {
|
|
349
|
+
const prefix = hostname.slice(0, -(apiHostSuffix.length + 1));
|
|
350
|
+
return `https://${prefix}.smith.langchain.com`;
|
|
351
|
+
}
|
|
352
|
+
return "https://smith.langchain.com";
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Compute the LangSmith dashboard URL for a deployment, if possible.
|
|
356
|
+
*
|
|
357
|
+
* @param updated - A deployment object that may carry a `tenant_id`.
|
|
358
|
+
* @param deploymentId - The deployment ID.
|
|
359
|
+
* @param hostUrl - The host backend API URL, used to derive the dashboard host.
|
|
360
|
+
* @returns The dashboard URL, or `null` when no tenant ID is available.
|
|
361
|
+
*/
|
|
362
|
+
export function getDeploymentStatusUrl(updated, deploymentId, hostUrl) {
|
|
363
|
+
const tenantId = updated && typeof updated === "object"
|
|
364
|
+
? updated.tenant_id
|
|
365
|
+
: undefined;
|
|
366
|
+
if (!tenantId)
|
|
367
|
+
return null;
|
|
368
|
+
const base = smithDashboardBaseUrl(hostUrl);
|
|
369
|
+
return `${base}/o/${tenantId}/host/deployments/${deploymentId}`;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Find a deployment ID by exact name match within a list response.
|
|
373
|
+
*
|
|
374
|
+
* @param response - A list-deployments response with a `resources` array.
|
|
375
|
+
* @param name - The exact deployment name to match, may be nullish.
|
|
376
|
+
* @returns The matching deployment ID, or `null` when not found.
|
|
377
|
+
*/
|
|
378
|
+
export function findDeploymentIdByName(response, name) {
|
|
379
|
+
if (!name)
|
|
380
|
+
return null;
|
|
381
|
+
if (response && typeof response === "object") {
|
|
382
|
+
const resources = response.resources;
|
|
383
|
+
if (Array.isArray(resources)) {
|
|
384
|
+
for (const dep of resources) {
|
|
385
|
+
if (dep &&
|
|
386
|
+
typeof dep === "object" &&
|
|
387
|
+
dep.name === name) {
|
|
388
|
+
const id = dep.id;
|
|
389
|
+
if (id)
|
|
390
|
+
return String(id);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared ignore-file handling for local source filtering, ported from the
|
|
3
|
+
* Python CLI's `_ignore.py`. Combines a set of always-excluded directories
|
|
4
|
+
* with patterns from `.dockerignore` (and optionally `.gitignore`), using
|
|
5
|
+
* gitignore semantics via the `ignore` package.
|
|
6
|
+
*/
|
|
7
|
+
import ignore, {} from "ignore";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { promises as fs } from "node:fs";
|
|
10
|
+
/** Directories always excluded from source filtering, regardless of config. */
|
|
11
|
+
const ALWAYS_EXCLUDE = [
|
|
12
|
+
"__pycache__/",
|
|
13
|
+
".git/",
|
|
14
|
+
".venv/",
|
|
15
|
+
"venv/",
|
|
16
|
+
"node_modules/",
|
|
17
|
+
".tox/",
|
|
18
|
+
".mypy_cache/",
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* Base names of the {@link ALWAYS_EXCLUDE} directories, used to prune the
|
|
22
|
+
* filesystem walk before consulting the gitignore matcher.
|
|
23
|
+
*/
|
|
24
|
+
export const ALWAYS_EXCLUDE_NAMES = new Set(ALWAYS_EXCLUDE.map((pattern) => pattern.replace(/\/$/, "").split("/").pop()));
|
|
25
|
+
/** Characters that mark a path segment as a glob pattern. */
|
|
26
|
+
const GLOB_CHARS = ["*", "?", "["];
|
|
27
|
+
/** Whether `ancestor` is a strict path-prefix (ancestor) of `descendant`. */
|
|
28
|
+
function isAncestor(ancestor, descendant) {
|
|
29
|
+
return descendant.startsWith(`${ancestor}/`);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Build {@link NegationHints} from raw ignore-file lines.
|
|
33
|
+
*
|
|
34
|
+
* @param lines - Raw lines from the loaded ignore files.
|
|
35
|
+
* @returns The aggregated negation hints.
|
|
36
|
+
*/
|
|
37
|
+
function buildNegationHints(lines) {
|
|
38
|
+
const exactDirs = new Set();
|
|
39
|
+
const wildcardPrefixes = new Set();
|
|
40
|
+
let recurseAll = false;
|
|
41
|
+
for (const rawLine of lines) {
|
|
42
|
+
let line = rawLine.trim();
|
|
43
|
+
if (!line || line.startsWith("#") || line.startsWith("\\!"))
|
|
44
|
+
continue;
|
|
45
|
+
if (line.startsWith("\\#"))
|
|
46
|
+
line = line.slice(1);
|
|
47
|
+
if (!line.startsWith("!"))
|
|
48
|
+
continue;
|
|
49
|
+
let pattern = line.slice(1).replace(/^\/+/, "");
|
|
50
|
+
while (pattern.startsWith("./"))
|
|
51
|
+
pattern = pattern.slice(2);
|
|
52
|
+
pattern = pattern.replace(/\/+$/, "");
|
|
53
|
+
const parts = pattern.split("/").filter((part) => part && part !== ".");
|
|
54
|
+
if (!parts.length) {
|
|
55
|
+
recurseAll = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const wildcardIndex = parts.findIndex((part) => GLOB_CHARS.some((char) => part.includes(char)));
|
|
59
|
+
if (wildcardIndex !== -1) {
|
|
60
|
+
const literalParts = parts.slice(0, wildcardIndex);
|
|
61
|
+
if (!literalParts.length) {
|
|
62
|
+
recurseAll = true;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
wildcardPrefixes.add(literalParts.join("/"));
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const parentParts = parts.slice(0, -1);
|
|
69
|
+
for (let idx = 1; idx <= parentParts.length; idx += 1) {
|
|
70
|
+
exactDirs.add(parentParts.slice(0, idx).join("/"));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { recurseAll, exactDirs, wildcardPrefixes: [...wildcardPrefixes] };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Whether the directory at `relPath` must be walked given the negation hints.
|
|
77
|
+
*
|
|
78
|
+
* @param hints - The aggregated negation hints.
|
|
79
|
+
* @param relPath - Directory path relative to the spec root (no trailing `/`).
|
|
80
|
+
* @returns `true` if the directory must be walked despite being ignored.
|
|
81
|
+
*/
|
|
82
|
+
function requiresDirWalkWith(hints, relPath) {
|
|
83
|
+
if (hints.recurseAll || hints.exactDirs.has(relPath))
|
|
84
|
+
return true;
|
|
85
|
+
return hints.wildcardPrefixes.some((prefix) => relPath === prefix ||
|
|
86
|
+
isAncestor(relPath, prefix) ||
|
|
87
|
+
isAncestor(prefix, relPath));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Read a text file split into lines, returning `null` if it does not exist or
|
|
91
|
+
* is not a regular file.
|
|
92
|
+
*
|
|
93
|
+
* @param file - Absolute path to the file to read.
|
|
94
|
+
* @returns The file's lines, or `null` when unreadable/missing.
|
|
95
|
+
*/
|
|
96
|
+
async function readLines(file) {
|
|
97
|
+
try {
|
|
98
|
+
const stat = await fs.stat(file);
|
|
99
|
+
if (!stat.isFile())
|
|
100
|
+
return null;
|
|
101
|
+
const content = await fs.readFile(file, "utf-8");
|
|
102
|
+
return content.split(/\r?\n/);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Build an {@link IgnoreSpec} for `directory`.
|
|
110
|
+
*
|
|
111
|
+
* @remarks
|
|
112
|
+
* Always excludes the common non-source directories in {@link ALWAYS_EXCLUDE}.
|
|
113
|
+
* On top of that, `.dockerignore` patterns are merged in, and `.gitignore`
|
|
114
|
+
* patterns are added when `includeGitignore` is enabled (archive creation
|
|
115
|
+
* wants both, while Docker build-context semantics want only `.dockerignore`).
|
|
116
|
+
*
|
|
117
|
+
* @param directory - Absolute directory whose ignore files are loaded.
|
|
118
|
+
* @param options - Behavior options.
|
|
119
|
+
* @param options.includeGitignore - Merge `.gitignore` patterns too (default
|
|
120
|
+
* `true`).
|
|
121
|
+
* @returns A spec exposing {@link IgnoreSpec.ignores} and `hasNegation`.
|
|
122
|
+
*/
|
|
123
|
+
export async function buildIgnoreSpec(directory, options = {}) {
|
|
124
|
+
const includeGitignore = options.includeGitignore ?? true;
|
|
125
|
+
const lines = [...ALWAYS_EXCLUDE];
|
|
126
|
+
// Raw negation-bearing lines from the loaded ignore files (excluding the
|
|
127
|
+
// built-in ALWAYS_EXCLUDE, which never contains negations) used to compute
|
|
128
|
+
// which ignored directories must still be walked.
|
|
129
|
+
const ignoreFileLines = [];
|
|
130
|
+
let hasNegation = false;
|
|
131
|
+
const ignoreFiles = [".dockerignore"];
|
|
132
|
+
if (includeGitignore)
|
|
133
|
+
ignoreFiles.push(".gitignore");
|
|
134
|
+
for (const name of ignoreFiles) {
|
|
135
|
+
const fileLines = await readLines(path.join(directory, name));
|
|
136
|
+
if (!fileLines)
|
|
137
|
+
continue;
|
|
138
|
+
for (const raw of fileLines) {
|
|
139
|
+
const line = raw.trim();
|
|
140
|
+
if (line && !line.startsWith("#") && line.startsWith("!")) {
|
|
141
|
+
hasNegation = true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
ignoreFileLines.push(...fileLines);
|
|
145
|
+
lines.push(...fileLines);
|
|
146
|
+
}
|
|
147
|
+
const ig = ignore().add(lines);
|
|
148
|
+
const negationHints = buildNegationHints(ignoreFileLines);
|
|
149
|
+
return {
|
|
150
|
+
ignores(relPath) {
|
|
151
|
+
// The `ignore` package rejects "." and absolute paths.
|
|
152
|
+
if (!relPath || relPath === ".")
|
|
153
|
+
return false;
|
|
154
|
+
return ig.ignores(relPath);
|
|
155
|
+
},
|
|
156
|
+
hasNegation,
|
|
157
|
+
requiresDirWalk(relPath) {
|
|
158
|
+
if (!relPath || relPath === ".")
|
|
159
|
+
return true;
|
|
160
|
+
return requiresDirWalkWith(negationHints, relPath);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|