@datarails-ai/dr-cli 1.6.152 → 1.6.153
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.js
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath as fileURLToPath3 } from "url";
|
|
|
8
8
|
// package.json
|
|
9
9
|
var package_default = {
|
|
10
10
|
name: "@datarails-ai/dr-cli",
|
|
11
|
-
version: "1.6.
|
|
11
|
+
version: "1.6.153",
|
|
12
12
|
description: "Datarails CLI",
|
|
13
13
|
type: "module",
|
|
14
14
|
engines: {
|
|
@@ -69,7 +69,7 @@ var package_default = {
|
|
|
69
69
|
};
|
|
70
70
|
|
|
71
71
|
// src/buildInfo.ts
|
|
72
|
-
var BUILD_TIME = "2026-10-
|
|
72
|
+
var BUILD_TIME = "2026-10-22T11:50:40.297Z";
|
|
73
73
|
function formatBuildTime(iso) {
|
|
74
74
|
if (iso === "") return "";
|
|
75
75
|
const d = new Date(iso);
|
|
@@ -69877,18 +69877,18 @@ async function preflightFile(filePath) {
|
|
|
69877
69877
|
`${filePath}: unsupported file type "${ext}". Supported: ${[...EXT_TO_MIME.keys()].join(", ")}`
|
|
69878
69878
|
);
|
|
69879
69879
|
}
|
|
69880
|
-
let
|
|
69880
|
+
let stat4;
|
|
69881
69881
|
try {
|
|
69882
|
-
|
|
69882
|
+
stat4 = await fsPromises3.stat(filePath);
|
|
69883
69883
|
} catch (err) {
|
|
69884
69884
|
const m = err instanceof Error ? err.message : String(err);
|
|
69885
69885
|
throw new Error(`${filePath}: ${m}`);
|
|
69886
69886
|
}
|
|
69887
|
-
if (!
|
|
69887
|
+
if (!stat4.isFile()) {
|
|
69888
69888
|
throw new Error(`${filePath}: not a regular file`);
|
|
69889
69889
|
}
|
|
69890
|
-
if (
|
|
69891
|
-
const mb = (
|
|
69890
|
+
if (stat4.size > MAX_FILE_BYTES) {
|
|
69891
|
+
const mb = (stat4.size / (1024 * 1024)).toFixed(1);
|
|
69892
69892
|
throw new Error(
|
|
69893
69893
|
`${filePath}: file is ${mb}MB, exceeds ${MAX_FILE_BYTES / (1024 * 1024)}MB limit`
|
|
69894
69894
|
);
|
|
@@ -72634,7 +72634,7 @@ async function resolveTicketTeam(client, team, signal) {
|
|
|
72634
72634
|
function errText2(err) {
|
|
72635
72635
|
return err instanceof Error ? err.message : String(err);
|
|
72636
72636
|
}
|
|
72637
|
-
async function planAttachments(paths,
|
|
72637
|
+
async function planAttachments(paths, stat4 = async (p) => {
|
|
72638
72638
|
const st = await fs34.stat(p);
|
|
72639
72639
|
return { size: st.size, isFile: st.isFile() };
|
|
72640
72640
|
}) {
|
|
@@ -72657,7 +72657,7 @@ async function planAttachments(paths, stat3 = async (p) => {
|
|
|
72657
72657
|
}
|
|
72658
72658
|
let info;
|
|
72659
72659
|
try {
|
|
72660
|
-
info = await
|
|
72660
|
+
info = await stat4(filePath);
|
|
72661
72661
|
} catch (err) {
|
|
72662
72662
|
throw new Error(`--attach ${filePath}: ${errText2(err)}`);
|
|
72663
72663
|
}
|
|
@@ -73045,8 +73045,8 @@ async function readAppPassword(src, stdinReader) {
|
|
|
73045
73045
|
let raw2;
|
|
73046
73046
|
if (fromFile) {
|
|
73047
73047
|
try {
|
|
73048
|
-
const
|
|
73049
|
-
if (!
|
|
73048
|
+
const stat4 = await fs34.stat(src.file);
|
|
73049
|
+
if (!stat4.isFile()) {
|
|
73050
73050
|
throw new Error(
|
|
73051
73051
|
"--password-file must be a regular file: a device or pipe can yield without end, and its reported size means nothing"
|
|
73052
73052
|
);
|
|
@@ -74132,7 +74132,7 @@ function registerAppSpaceCommand(root) {
|
|
|
74132
74132
|
|
|
74133
74133
|
// src/commands/agents.ts
|
|
74134
74134
|
import { mkdir as mkdir5, writeFile as writeFile8 } from "fs/promises";
|
|
74135
|
-
import { join as
|
|
74135
|
+
import { join as join18 } from "path";
|
|
74136
74136
|
|
|
74137
74137
|
// src/api/aiService.ts
|
|
74138
74138
|
import { z as z3 } from "zod";
|
|
@@ -74500,13 +74500,26 @@ function renderScopeRegistry(registry) {
|
|
|
74500
74500
|
const lines = [
|
|
74501
74501
|
`Actions: ${[...actions, "*"].join(" | ")} (* = every action). Resource * = every resource.`
|
|
74502
74502
|
];
|
|
74503
|
+
const unenforced = [];
|
|
74503
74504
|
for (const product of Array.isArray(registry?.products) ? registry.products : []) {
|
|
74504
|
-
|
|
74505
|
+
const productName = product.name ?? product.key ?? "?";
|
|
74506
|
+
const accepted = [];
|
|
74505
74507
|
for (const resource of product.resources ?? []) {
|
|
74506
|
-
const note = resource.enforced === false ? " (not enforced yet)" : "";
|
|
74507
74508
|
const description = resource.description ? ` \u2014 ${resource.description}` : "";
|
|
74508
|
-
|
|
74509
|
+
if (resource.enforced === false) {
|
|
74510
|
+
unenforced.push(` ${resource.name ?? "?"} (${productName})${description}`);
|
|
74511
|
+
} else {
|
|
74512
|
+
accepted.push(` ${resource.name ?? "?"}${description}`);
|
|
74513
|
+
}
|
|
74509
74514
|
}
|
|
74515
|
+
if (accepted.length > 0) lines.push("", productName, ...accepted);
|
|
74516
|
+
}
|
|
74517
|
+
if (unenforced.length > 0) {
|
|
74518
|
+
lines.push(
|
|
74519
|
+
"",
|
|
74520
|
+
"Not enforced yet \u2014 listed for reference; --cli-scopes does not accept these until their product checks them:",
|
|
74521
|
+
...unenforced
|
|
74522
|
+
);
|
|
74510
74523
|
}
|
|
74511
74524
|
return lines.join("\n");
|
|
74512
74525
|
}
|
|
@@ -74526,10 +74539,13 @@ function cliScopesWriteLine(requested, after) {
|
|
|
74526
74539
|
if (requested === null && after !== null) {
|
|
74527
74540
|
return `WARNING: CLI scopes reset was sent but a ceiling is still stored: ${after.join(", ")}.`;
|
|
74528
74541
|
}
|
|
74542
|
+
if (requested !== null && requested.length > 0 && after === null) {
|
|
74543
|
+
return `WARNING: CLI scopes ${requested.join(", ")} were sent but nothing is stored: the requested ceiling did not land, and the read-only default (${DEFAULT_CLI_SCOPES.join(", ")}) applies.`;
|
|
74544
|
+
}
|
|
74529
74545
|
return `CLI scopes: ${cliScopesLabel(after)}. Applies from the agent's next session; running sessions keep the ceiling they started with.`;
|
|
74530
74546
|
}
|
|
74531
74547
|
var SCOPE_ERROR_HINTS = {
|
|
74532
|
-
INVALID_CLI_SCOPES:
|
|
74548
|
+
INVALID_CLI_SCOPES: 'run `agents scope-registry` for the resources and actions this environment knows; resources it marks "not enforced yet" are not accepted.',
|
|
74533
74549
|
CLI_SCOPES_WITHOUT_CLI: "CLI scopes only apply to a CLI-exposed agent. Add `--expose-cli true` (update) or `--expose-cli` (create), or use --reset-cli-scopes to clear them."
|
|
74534
74550
|
};
|
|
74535
74551
|
function describeCliScopesError(err) {
|
|
@@ -75080,10 +75096,11 @@ function renderToolsProposalText(proposal, resolved) {
|
|
|
75080
75096
|
|
|
75081
75097
|
// src/commands/agents_skills.ts
|
|
75082
75098
|
import { mkdir as mkdir4, writeFile as writeFile7 } from "fs/promises";
|
|
75083
|
-
import { dirname as dirname9, join as
|
|
75099
|
+
import { dirname as dirname9, join as join17 } from "path";
|
|
75084
75100
|
|
|
75085
75101
|
// src/commands/_agentSkillBundle.ts
|
|
75086
|
-
import {
|
|
75102
|
+
import { readdir as readdir4, stat as stat3 } from "fs/promises";
|
|
75103
|
+
import { extname as extname5, join as join16 } from "path";
|
|
75087
75104
|
function inferContentType(filename) {
|
|
75088
75105
|
switch (extname5(filename).toLowerCase()) {
|
|
75089
75106
|
case ".md":
|
|
@@ -75121,7 +75138,60 @@ function inferContentType(filename) {
|
|
|
75121
75138
|
return void 0;
|
|
75122
75139
|
}
|
|
75123
75140
|
}
|
|
75124
|
-
|
|
75141
|
+
var SKILL_BUNDLE_LIMITS = {
|
|
75142
|
+
maxFileBytes: 20 * 1024 * 1024,
|
|
75143
|
+
maxTotalBytes: 14 * 1024 * 1024
|
|
75144
|
+
};
|
|
75145
|
+
var SKILL_ENTRY_OVERHEAD_BYTES = 128;
|
|
75146
|
+
async function statSkillFiles(skillRoot) {
|
|
75147
|
+
const out = [];
|
|
75148
|
+
async function walk(dir, prefix) {
|
|
75149
|
+
for (const e of await readdir4(dir, { withFileTypes: true })) {
|
|
75150
|
+
if (isIgnoredSkillEntry(e.name)) continue;
|
|
75151
|
+
const childAbs = join16(dir, e.name);
|
|
75152
|
+
const childRel = prefix === "" ? e.name : `${prefix}/${e.name}`;
|
|
75153
|
+
if (e.isDirectory()) {
|
|
75154
|
+
await walk(childAbs, childRel);
|
|
75155
|
+
} else if (e.isFile()) {
|
|
75156
|
+
out.push({ relPath: childRel, size: (await stat3(childAbs)).size });
|
|
75157
|
+
}
|
|
75158
|
+
}
|
|
75159
|
+
}
|
|
75160
|
+
await walk(skillRoot, "");
|
|
75161
|
+
out.sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
75162
|
+
return out;
|
|
75163
|
+
}
|
|
75164
|
+
function assertUploadableSizes(files, limits) {
|
|
75165
|
+
const oversized = files.find((f) => f.size > limits.maxFileBytes);
|
|
75166
|
+
if (oversized !== void 0) {
|
|
75167
|
+
throw new Error(
|
|
75168
|
+
`skill file "${oversized.relPath}" is ${oversized.size} bytes; ai-service accepts at most ${limits.maxFileBytes} bytes per file`
|
|
75169
|
+
);
|
|
75170
|
+
}
|
|
75171
|
+
const empty = files.filter((f) => f.size === 0).map((f) => `"${f.relPath}"`);
|
|
75172
|
+
if (empty.length === 1) {
|
|
75173
|
+
throw new Error(
|
|
75174
|
+
`skill file ${empty[0]} is empty; ai-service rejects empty files \u2014 add content or remove it`
|
|
75175
|
+
);
|
|
75176
|
+
}
|
|
75177
|
+
if (empty.length > 1) {
|
|
75178
|
+
throw new Error(
|
|
75179
|
+
`skill files ${empty.join(", ")} are empty; ai-service rejects empty files \u2014 add content or remove them`
|
|
75180
|
+
);
|
|
75181
|
+
}
|
|
75182
|
+
const total = files.reduce(
|
|
75183
|
+
(sum, f) => sum + f.size + Buffer.byteLength(f.relPath) + SKILL_ENTRY_OVERHEAD_BYTES,
|
|
75184
|
+
0
|
|
75185
|
+
);
|
|
75186
|
+
if (total > limits.maxTotalBytes) {
|
|
75187
|
+
const largest = files.reduce((a, b) => b.size > a.size ? b : a);
|
|
75188
|
+
throw new Error(
|
|
75189
|
+
`skill bundle is ${total} bytes counting its paths (${SKILL_ENTRY_OVERHEAD_BYTES} bytes per file on top); ai-service accepts at most ${limits.maxTotalBytes} bytes in one upload (${files.length} files; largest: "${largest.relPath}", ${largest.size} bytes)`
|
|
75190
|
+
);
|
|
75191
|
+
}
|
|
75192
|
+
}
|
|
75193
|
+
async function dirToFiles(baseDir, limits = SKILL_BUNDLE_LIMITS) {
|
|
75194
|
+
assertUploadableSizes(await statSkillFiles(baseDir), limits);
|
|
75125
75195
|
return (await readSkillFiles(baseDir)).map(({ relPath, bytes }) => {
|
|
75126
75196
|
const contentType = inferContentType(relPath);
|
|
75127
75197
|
return {
|
|
@@ -75289,7 +75359,7 @@ async function runSkillsPull(cfg) {
|
|
|
75289
75359
|
const folder = bundleFolder(files);
|
|
75290
75360
|
const targets = files.map((file) => ({
|
|
75291
75361
|
file,
|
|
75292
|
-
outPath:
|
|
75362
|
+
outPath: join17(baseDir, ...folder, ...relSegments(file.path))
|
|
75293
75363
|
}));
|
|
75294
75364
|
await assertNoOverwrite(
|
|
75295
75365
|
targets.map((t) => t.outPath),
|
|
@@ -75537,6 +75607,11 @@ async function runAgentsSystemEntities(cfg) {
|
|
|
75537
75607
|
const ids = (list28) => (list28 ?? []).length > 0 ? (list28 ?? []).join(", ") : "(none)";
|
|
75538
75608
|
cfg.out.write(`agents: ${ids(res.agentIds)}
|
|
75539
75609
|
`);
|
|
75610
|
+
const protectedIds = res.protectedAgentIds;
|
|
75611
|
+
cfg.out.write(
|
|
75612
|
+
Array.isArray(protectedIds) ? `protected: ${ids(protectedIds)} (no archive or reach change; content stays editable)
|
|
75613
|
+
` : "protected: (not reported by this ai-service)\n"
|
|
75614
|
+
);
|
|
75540
75615
|
cfg.out.write(`environments: ${ids(res.environmentIds)}
|
|
75541
75616
|
`);
|
|
75542
75617
|
cfg.out.write(`skills: ${ids(res.skillIds)}
|
|
@@ -75773,6 +75848,11 @@ async function runAgentsRefresh(cfg) {
|
|
|
75773
75848
|
const apis = toCatalogEntries(catalog.apis ?? []);
|
|
75774
75849
|
const name = agent.name ?? agentId;
|
|
75775
75850
|
const v = versionLabel2(agent.version);
|
|
75851
|
+
if (!Array.isArray(agent.tools)) {
|
|
75852
|
+
throw new Error(
|
|
75853
|
+
`getAgent returned no tools list for ${agentId} ${v}; refusing to plan or write a tools change`
|
|
75854
|
+
);
|
|
75855
|
+
}
|
|
75776
75856
|
if (proposal === void 0) {
|
|
75777
75857
|
const plan = computeRefreshPlan(agent.tools, apis, topN);
|
|
75778
75858
|
if (cfg.asJson) {
|
|
@@ -75886,15 +75966,15 @@ async function runAgentsPull(cfg) {
|
|
|
75886
75966
|
throw new Error(`${agentId}: the response carried no system prompt, tools or skills to pull`);
|
|
75887
75967
|
}
|
|
75888
75968
|
await assertNoOverwrite(
|
|
75889
|
-
files.map((f) =>
|
|
75969
|
+
files.map((f) => join18(baseDir, f.name)),
|
|
75890
75970
|
cfg.force === true
|
|
75891
75971
|
);
|
|
75892
75972
|
await mkdir5(baseDir, { recursive: true });
|
|
75893
|
-
for (const f of files) await writeFile8(
|
|
75973
|
+
for (const f of files) await writeFile8(join18(baseDir, f.name), f.content);
|
|
75894
75974
|
cfg.out.write(`Wrote ${files.map((f) => f.name).join(", ")} to ${baseDir}/
|
|
75895
75975
|
`);
|
|
75896
75976
|
const base = typeof agent.version === "number" ? ` --version-number ${agent.version}` : "";
|
|
75897
|
-
const fileFlags = files.map((f) => `${f.flag} ${shellArg(
|
|
75977
|
+
const fileFlags = files.map((f) => `${f.flag} ${shellArg(join18(baseDir, f.name))}`).join(" ");
|
|
75898
75978
|
cfg.out.write(
|
|
75899
75979
|
`Edit, then: dr agents update --id ${shellArg(agentId)}${base} ${fileFlags} ${serverFlagText(cfg.client)}
|
|
75900
75980
|
`
|
|
@@ -76486,7 +76566,7 @@ function registerAgentsCommand(root) {
|
|
|
76486
76566
|
});
|
|
76487
76567
|
withTarget(cliScopes);
|
|
76488
76568
|
const scopeRegistry = agents.command("scope-registry").description(
|
|
76489
|
-
|
|
76569
|
+
'List the resources and actions a CLI scope may name in this environment. Resources listed under "Not enforced yet" are not accepted by --cli-scopes.'
|
|
76490
76570
|
).option("--json", JSON_HELP).action(async (opts) => {
|
|
76491
76571
|
const setup = await setupCall(opts.server, opts.account);
|
|
76492
76572
|
try {
|
|
@@ -76891,7 +76971,7 @@ function commandPath(actionCommand) {
|
|
|
76891
76971
|
|
|
76892
76972
|
// src/telemetry/config.ts
|
|
76893
76973
|
import { homedir as homedir7 } from "os";
|
|
76894
|
-
import { join as
|
|
76974
|
+
import { join as join20 } from "path";
|
|
76895
76975
|
import { readFile as readFile9 } from "fs/promises";
|
|
76896
76976
|
var TELEMETRY_ENV = "DR_CLI_TELEMETRY";
|
|
76897
76977
|
var DISABLED_VALUES = /* @__PURE__ */ new Set(["0", "false", "off", "no"]);
|
|
@@ -76925,7 +77005,7 @@ function parseCollectorConfig(raw2) {
|
|
|
76925
77005
|
return { endpoint, headerName, headerValue };
|
|
76926
77006
|
}
|
|
76927
77007
|
function remoteSettingsPath() {
|
|
76928
|
-
return
|
|
77008
|
+
return join20(homedir7(), ".claude", "remote-settings.json");
|
|
76929
77009
|
}
|
|
76930
77010
|
async function readCollectorConfig(path13 = remoteSettingsPath()) {
|
|
76931
77011
|
let raw2;
|
package/dist/scopes-map.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"cliVersion": "1.6.
|
|
3
|
+
"cliVersion": "1.6.153",
|
|
4
4
|
"_contract": "Resolve a command path through `aliases` (canonical prefix + alias segment -> canonical path) before any lookup. A path listed in `pendingCommands`, or whose first segment is a key of `pending`, is UNDECIDED, not unrestricted: fail closed or skip it, and never read a path's absence from `commands` as 'no requirement'. An entry carrying `denied` is UNAVAILABLE to any AGENT-SCOPED credential — one whose `scopes` claim carries at least one `resource:action` entry; a claim that is absent, empty, capability-only or wholly unparseable is UNRESTRICTED, and this mark does not apply to it — whatever else that entry says: it is not a ceiling and it is not always-available, its `denied.reason` says why and `denied.until` names the ticket that lifts it, and the absence of `requires` on it must NEVER be read as 'no requirement'. Only an entry in `commands` with an empty `requires` means always-available.",
|
|
5
5
|
"commands": {
|
|
6
6
|
"login": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: schedule-drip-task
|
|
3
|
-
description: Use when a customer needs files or data pushed OUT of Datarails on a schedule through DRIP (the connectors platform) - an SFTP export, "send this CSV to the customer's SFTP every hour", "push the file to their server", "schedule an export", "scheduled task", "dr connectors tasks", "sftp_export", "lib/sftp_export", or when operating an existing schedule - "is the export running", "why did the export fail", "the task is paused", "run the export now", "pause the export", "task runs", "consecutive failures", "paused (failures)", "paused (schedule)", "no active version", "newest source file is older than max_age_seconds", "the target must not be a customer org-*-sftp container", a 403 "scheduled tasks need a support user or an org admin". Not for pulling data INTO Datarails (drip-implement-connector) or for polled SFTP ingestion (dr connectors poll).
|
|
3
|
+
description: Use when a customer needs files or data pushed OUT of Datarails on a schedule through DRIP (the connectors platform) - an SFTP export, "send this CSV to the customer's SFTP every hour", "push the file to their server", "schedule an export", "scheduled task", "dr connectors tasks", "sftp_export", "lib/sftp_export", or when operating an existing schedule - "is the export running", "why did the export fail", "the task is paused", "run the export now", "pause the export", "task runs", "consecutive failures", "paused (failures)", "paused (schedule)", "no active version", "newest source file is older than max_age_seconds", "the target must not be a customer org-*-sftp container", a 403 "scheduled tasks need a support user or an org admin", or when writing a new task type - "write a scheduled task", "new DRIP task", "process() TaskResult", "run hook", "task state". Not for pulling data INTO Datarails (drip-implement-connector) or for polled SFTP ingestion (dr connectors poll).
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Schedule a DRIP task
|
|
@@ -10,6 +10,9 @@ datasources, and keeps a run history. The control plane only schedules; the runn
|
|
|
10
10
|
artifact's `process()`. The first task type is **SFTP Export** (`lib/sftp_export`): copy files from
|
|
11
11
|
our hosted SFTP to a customer's SFTP under a name the customer wants.
|
|
12
12
|
|
|
13
|
+
To write a task type other than SFTP Export (the `process()` hook, `TaskResult`, state, retries),
|
|
14
|
+
see [reference/writing-a-task.md](reference/writing-a-task.md).
|
|
15
|
+
|
|
13
16
|
Commands: `dr connectors tasks create | list | get | update | delete | run | pause | resume | runs`.
|
|
14
17
|
Every one takes `--server`, `--account` and `--json`; a `<task>` is its id or its name.
|
|
15
18
|
|
|
@@ -16,10 +16,11 @@ datasources (the control plane refuses secret-looking keys with a 422).
|
|
|
16
16
|
| `settle_seconds` | no | 120 | Skip files modified more recently (the producer may still be writing). 0 to 7 days |
|
|
17
17
|
| `max_age_seconds` | no | none | `newest` only: fail the run if the newest file is older than this before the scheduled time, or if no file matches (producer late or stopped) |
|
|
18
18
|
| `max_files_per_run` | no | 50 | `all_new`: at most this many files per run (1 to 1000); the rest go next run |
|
|
19
|
+
| `upload` | no | `atomic` | `atomic`: write a `.part`, then rename it, so no reader sees a half-written file. `direct`: write the final name itself, for a target folder that takes files away as they arrive (the `.part` would be gone before the rename). `newest` only |
|
|
19
20
|
|
|
20
21
|
Behaviour worth knowing:
|
|
21
22
|
|
|
22
|
-
- Upload is atomic: a random `.<name>.<hex>.part` is written, then renamed. The customer's login must
|
|
23
|
+
- Upload is atomic by default: a random `.<name>.<hex>.part` is written, then renamed. A run that fails with "a file vanished while publishing the upload" means the target takes files as they arrive: set `"upload": "direct"`. The customer's login must
|
|
23
24
|
be allowed to create and rename files in `target_dir`. Stale parts of ours older than 2 h are swept.
|
|
24
25
|
- The source is never written, moved or deleted. Cleaning it is the producer's job; with `all_new`,
|
|
25
26
|
a folder that keeps every sent file eventually fails the run with "archive or delete them".
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Writing a new task type
|
|
2
|
+
|
|
3
|
+
A scheduled task is an ordinary DRIP artifact in dr-connectors (a connection plus a dataquery,
|
|
4
|
+
published and made active like any connector, see `drip-implement-connector`), whose dataquery
|
|
5
|
+
implements `process()`. The control plane only schedules it. On each run it sends the flavor's
|
|
6
|
+
runner a job with `hook: "run"`, and the runner calls `connect()`, then `process()`, in one job.
|
|
7
|
+
The full contract is in dr-connectors-runner `docs/connector-kit.md`, "Scheduled tasks: the `run`
|
|
8
|
+
hook". `lib/sftp_export` in dr-connectors is the reference implementation.
|
|
9
|
+
|
|
10
|
+
## The code
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
# lib/<task>/dataqueries/<name>.py
|
|
14
|
+
from dr_integrations_sdk import DataQuery, PermanentError, SyncRequest, TaskResult, kit
|
|
15
|
+
|
|
16
|
+
OBJECT_TYPES = ("<objectType>",)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MyTask(DataQuery):
|
|
20
|
+
async def process(self, session, req: SyncRequest) -> TaskResult:
|
|
21
|
+
config = req.config # the task's --config, validated here
|
|
22
|
+
state = (req.metadata or {}).get("state") or {} # what the last ok run returned
|
|
23
|
+
scheduled_for = req.metadata["scheduled_for"] # ISO UTC time of the slot this run is for
|
|
24
|
+
other = await kit.auth.get_credentials_for(req, "source") # a non-primary datasource, by role
|
|
25
|
+
sent = await do_the_work(session, other, config, state)
|
|
26
|
+
return TaskResult(state={"sent": sent}, summary={"sent": len(sent)})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_query_class(object_type: str):
|
|
30
|
+
if object_type == "<objectType>":
|
|
31
|
+
return MyTask
|
|
32
|
+
raise PermanentError(f"unknown objectType {object_type!r}")
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
| Piece | Rule |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `connect()` | Gets the **primary** datasource's credentials as for any connector. Log in here, so a wrong password or host key fails before any work |
|
|
38
|
+
| `req.config` | The task's `--config` JSON. Must carry `objectType` (it picks the dataquery class). Validate it before any login. Never put credentials in it: the control plane refuses secret-looking keys with a 422 |
|
|
39
|
+
| Other datasources | `kit.auth.get_credentials_for(req, "<role>")`, one per `--datasource <role>=<id>`. An unbound role is a `PermanentError` naming it |
|
|
40
|
+
| `req.metadata` | `state`, `scheduled_for`, `run_id`, `attempt` (1, or 2 on the retry), `trigger` (`schedule` or `manual`) |
|
|
41
|
+
| Imports | stdlib, `dr_integrations_sdk`, `lib` and the flavor's allowlist only (dr-connector-images). Set `RUNTIME_FLAVOR` in the connection |
|
|
42
|
+
| Logs | `kit.logging.get_logger("<task>")`. They reach Groundcover under the runner flavor's workload |
|
|
43
|
+
|
|
44
|
+
## The result
|
|
45
|
+
|
|
46
|
+
`TaskResult(state=…, summary=…, verdict=…)`. Every field is optional.
|
|
47
|
+
|
|
48
|
+
- **`state`**: the task's own bookkeeping (ids, cursors, names already sent), never the data
|
|
49
|
+
processed. It must be a JSON object or `None`, at most 64 KiB. The control plane keeps it only
|
|
50
|
+
from an `ok` run; after a failure the next run gets the last good state.
|
|
51
|
+
- **`summary`**: what the run did, shown by `dr connectors tasks runs`.
|
|
52
|
+
- **`verdict`**: `ok` (default), `retry` (the run is sent once more after 60 s) or `block` (the run
|
|
53
|
+
fails).
|
|
54
|
+
- The whole result must be JSON (no `datetime`, `set`, `Decimal`, NaN) and at most 128 KiB.
|
|
55
|
+
Anything else fails the run as a connector error.
|
|
56
|
+
|
|
57
|
+
## Errors
|
|
58
|
+
|
|
59
|
+
| Raised | Run |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `PermanentError`, `AuthError` | Fails, no retry. Use for bad config, refused credentials, missing folders |
|
|
62
|
+
| `TransientError`, `httpx` 429/5xx, `httpx.TransportError` | Retried once after 60 s, then fails |
|
|
63
|
+
| Anything else | Fails as a connector error |
|
|
64
|
+
|
|
65
|
+
- After 5 failed runs in a row the task pauses itself (`paused (failures)`).
|
|
66
|
+
- The control plane stores only the first few hundred characters of a run's error. Keep the message
|
|
67
|
+
short. Log anything longer, such as a key or a listing, and read it in Groundcover.
|
|
68
|
+
- Never put a secret in an error or a log line.
|
|
69
|
+
|
|
70
|
+
## Make it safe to run twice
|
|
71
|
+
|
|
72
|
+
A retry runs `process()` again, a manual run can land next to a scheduled one, and a run that dies
|
|
73
|
+
leaves no state. So:
|
|
74
|
+
|
|
75
|
+
- Record what was done in `state`, and skip it next time.
|
|
76
|
+
- Write the output so that a half-finished run leaves nothing a reader would take as done. For
|
|
77
|
+
example, upload to a temporary name and rename it.
|
|
78
|
+
- Use `scheduled_for`, not the clock, for anything named after the run's time: a retry then makes
|
|
79
|
+
the same name.
|
|
80
|
+
|
|
81
|
+
## Ship it
|
|
82
|
+
|
|
83
|
+
1. **Shared code goes in `lib/<task>/`**, with tests. A thin per-org artifact re-exports it. The
|
|
84
|
+
dataquery must set its own `CONNECTION` (the thin connection file's name), as in
|
|
85
|
+
[SKILL.md](../SKILL.md) step 3.
|
|
86
|
+
2. **Merge to dr-connectors.** CI publishes the artifact to JFrog.
|
|
87
|
+
3. **Register and activate it on the region:** `dr connectors publish <dataquery file>`, then
|
|
88
|
+
`dr connectors promote <artifact> --artifact-version v<N>`, as the customer's support user.
|
|
89
|
+
4. **Schedule it:** `dr connectors tasks create` (see [SKILL.md](../SKILL.md)), then `tasks run` once
|
|
90
|
+
and check `tasks runs`.
|
|
91
|
+
5. **Test on dev-1 first.** The heartbeat artifact in dr-connectors
|
|
92
|
+
(`dev-1/69_(test-connectors.com)/heartbeat/`) is a minimal task that carries a counter in `state`.
|