runline 0.21.0 → 0.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.
@@ -0,0 +1,118 @@
1
+ import { openAsBlob } from "node:fs";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { extname } from "node:path";
4
+ /**
5
+ * The Shift cloud signed-grant upload arc, shared by every plugin that
6
+ * moves local file bytes into the cloud (shiftTranscription assets,
7
+ * shiftObjects objects): stat + size-gate the file, create the server
8
+ * record with { contentType, sizeBytes }, PUT the bytes to the grant
9
+ * URL with the grant's headers, then POST /complete. The subtle parts
10
+ * live here once: the openAsBlob streaming fallback, grant-header
11
+ * merging, and the size ceilings.
12
+ *
13
+ * shiftOcr deliberately does not use this arc — it inlines small files
14
+ * as base64 data URLs instead of uploading through a grant.
15
+ */
16
+ /** Mirrors the public service contracts' 5 GiB upload ceiling. */
17
+ export const SIGNED_UPLOAD_MAX_BYTES = 5 * 1024 * 1024 * 1024;
18
+ /**
19
+ * Runtimes without fs.openAsBlob fall back to buffering; refuse to
20
+ * buffer files that would strain the host process.
21
+ */
22
+ const MAX_BUFFERED_BYTES = 512 * 1024 * 1024;
23
+ /** Audio and video formats the transcription service accepts. */
24
+ export const AUDIO_VIDEO_MEDIA_TYPES = {
25
+ aac: "audio/aac",
26
+ flac: "audio/flac",
27
+ m4a: "audio/m4a",
28
+ mov: "video/quicktime",
29
+ mp3: "audio/mpeg",
30
+ mp4: "video/mp4",
31
+ mpeg: "video/mpeg",
32
+ mpg: "video/mpeg",
33
+ oga: "audio/ogg",
34
+ ogg: "audio/ogg",
35
+ opus: "audio/ogg",
36
+ wav: "audio/wav",
37
+ wave: "audio/wav",
38
+ webm: "video/webm",
39
+ };
40
+ /** Common document, image, and media formats for durable objects. */
41
+ export const GENERAL_MEDIA_TYPES = {
42
+ csv: "text/csv",
43
+ doc: "application/msword",
44
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
45
+ gif: "image/gif",
46
+ html: "text/html",
47
+ jpeg: "image/jpeg",
48
+ jpg: "image/jpeg",
49
+ json: "application/json",
50
+ md: "text/markdown",
51
+ mp3: "audio/mpeg",
52
+ mp4: "video/mp4",
53
+ pdf: "application/pdf",
54
+ png: "image/png",
55
+ txt: "text/plain",
56
+ wav: "audio/wav",
57
+ webp: "image/webp",
58
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
59
+ zip: "application/zip",
60
+ };
61
+ /** Extension-based media type lookup; undefined when unknown. */
62
+ export function mediaTypeFromPath(path, types) {
63
+ return types[extname(path).slice(1).toLowerCase()];
64
+ }
65
+ /** Like {@link mediaTypeFromPath}, but unknown extensions are an error. */
66
+ export function requireMediaType(path, types) {
67
+ const mediaType = mediaTypeFromPath(path, types);
68
+ if (!mediaType) {
69
+ throw new Error(`Unsupported media extension for ${path}. Supported: ${Object.keys(types).join(", ")}`);
70
+ }
71
+ return mediaType;
72
+ }
73
+ /**
74
+ * Gate a local file before any network call: it must exist, be a real
75
+ * file, be non-empty, and fit the service ceiling. Returns its size.
76
+ * The label keeps each plugin's established error vocabulary
77
+ * ("File is empty" vs "Media file is empty").
78
+ */
79
+ export async function statUploadFile(path, maxBytes, label = "File") {
80
+ const file = await stat(path);
81
+ if (!file.isFile())
82
+ throw new Error(`${path} is not a file`);
83
+ if (file.size === 0)
84
+ throw new Error(`${label} is empty`);
85
+ if (file.size > maxBytes) {
86
+ throw new Error(`${label} is larger than the ${maxBytes}-byte service limit`);
87
+ }
88
+ return file.size;
89
+ }
90
+ async function fileBody(path, sizeBytes) {
91
+ try {
92
+ return await openAsBlob(path);
93
+ }
94
+ catch {
95
+ if (sizeBytes > MAX_BUFFERED_BYTES) {
96
+ throw new Error("This runtime cannot stream uploads and the file is too large to buffer.");
97
+ }
98
+ return await readFile(path);
99
+ }
100
+ }
101
+ /**
102
+ * PUT a local file's bytes to a signed grant. Grant headers win;
103
+ * content-type is filled in only when the grant leaves it unset.
104
+ */
105
+ export async function putThroughGrant(grant, path, contentType, sizeBytes, label = "Upload") {
106
+ const headers = new Headers(grant.headers);
107
+ if (!headers.has("content-type")) {
108
+ headers.set("content-type", contentType);
109
+ }
110
+ const response = await fetch(grant.url, {
111
+ method: grant.method,
112
+ headers,
113
+ body: await fileBody(path, sizeBytes),
114
+ });
115
+ if (!response.ok) {
116
+ throw new Error(`${label} failed: HTTP ${response.status}`);
117
+ }
118
+ }
@@ -1,5 +1,5 @@
1
1
  import * as t from "typebox";
2
- import { ATLAS_BASE, cursorSchema, enumSchema, GRAPH_ACTOR_KIND, GRAPH_ASSIGNMENT_ROLE, GRAPH_BINDING_EXECUTOR, GRAPH_BINDING_STATUS, GRAPH_CHANGE_ENTITY_TYPE, GRAPH_EVIDENCE_KIND, GRAPH_JOURNEY_STATUS, GRAPH_RELATION_KIND, GRAPH_TASK_MODE, idSchema, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, } from "./shared.js";
2
+ import { ATLAS_BASE, cursorSchema, enumSchema, GRAPH_ACTOR_KIND, GRAPH_ASSIGNMENT_ROLE, GRAPH_BINDING_EXECUTOR, GRAPH_BINDING_STATUS, GRAPH_CHANGE_ENTITY_TYPE, GRAPH_EVIDENCE_KIND, GRAPH_JOURNEY_STATUS, GRAPH_RELATION_KIND, GRAPH_TASK_MODE, idSchema, listParams, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, withQuery, } from "./shared.js";
3
3
  /**
4
4
  * The operational graph behind the Atlas view: a business spec of how
5
5
  * a company operates, in its own language. Four fixed levels — line →
@@ -121,12 +121,7 @@ export function registerGraphActions(rl) {
121
121
  limit: t.Optional(t.Integer({ minimum: 1, maximum: 200 })),
122
122
  }, STRICT_OBJECT),
123
123
  async execute(input, ctx) {
124
- const params = new URLSearchParams();
125
- for (const [key, value] of Object.entries(input)) {
126
- if (value !== undefined)
127
- params.set(key, String(value));
128
- }
129
- return request(ctx, `${ATLAS_BASE}/changes?${params}`);
124
+ return request(ctx, withQuery(`${ATLAS_BASE}/changes`, listParams(input)));
130
125
  },
131
126
  });
132
127
  /* -------------------------------------------------------------- */
@@ -1,4 +1,4 @@
1
- export { cursorSchema, enumSchema, idSchema, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, } from "../../_shared/shiftCloud.js";
1
+ export { cursorSchema, enumSchema, idSchema, listParams, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, withQuery, } from "../../_shared/shiftCloud.js";
2
2
  /** Base path of the operational graph service in the Shift cloud API. */
3
3
  export const ATLAS_BASE = "/v1/services/operational-graph";
4
4
  export const GRAPH_ACTOR_KIND = ["agent", "human_role"];
@@ -0,0 +1,20 @@
1
+ import * as t from "typebox";
2
+ import { registerObjectActions } from "./objects.js";
3
+ /**
4
+ * Shift Objects — the organization's durable object bucket in the
5
+ * Shift cloud: upload binary files through signed grants, mint
6
+ * download grants, and record append-only provenance links. The API
7
+ * key is the tenant authority; the cloud derives the organization from
8
+ * it (SHFT-852), so no org ID is sent.
9
+ */
10
+ export default function shiftObjects(rl) {
11
+ rl.setName("shiftObjects");
12
+ rl.setVersion("0.1.0");
13
+ rl.setConnectionSchema(t.Object({
14
+ apiKey: t.String({
15
+ description: "Shift Labs API key",
16
+ env: "SHIFT_LABS_API_KEY",
17
+ }),
18
+ }));
19
+ registerObjectActions(rl);
20
+ }
@@ -1,8 +1,8 @@
1
- import { openAsBlob } from "node:fs";
2
- import { readFile, stat, writeFile } from "node:fs/promises";
3
- import { basename, extname } from "node:path";
1
+ import { writeFile } from "node:fs/promises";
2
+ import { basename } from "node:path";
4
3
  import * as t from "typebox";
5
- import { enumSchema, pathSegment, request } from "./shared.js";
4
+ import { enumSchema, listParams, pathSegment, request, withQuery, } from "../../_shared/shiftCloud.js";
5
+ import { GENERAL_MEDIA_TYPES, mediaTypeFromPath, putThroughGrant, SIGNED_UPLOAD_MAX_BYTES, statUploadFile, } from "../../_shared/shiftUpload.js";
6
6
  const OBJECTS_BASE = "/v1/services/objects";
7
7
  export const OBJECT_STATUS = ["pending_upload", "ready", "archived"];
8
8
  export const OBJECT_LINK_TARGET = [
@@ -11,49 +11,8 @@ export const OBJECT_LINK_TARGET = [
11
11
  "eval_case",
12
12
  "db_record",
13
13
  ];
14
- /** Mirrors the public service contract's 5 GiB object ceiling. */
15
- const MAX_OBJECT_BYTES = 5 * 1024 * 1024 * 1024;
16
14
  const STRICT_OBJECT = { additionalProperties: false };
17
15
  const Id = t.String({ minLength: 1, pattern: "\\S" });
18
- const MEDIA_TYPES = {
19
- csv: "text/csv",
20
- doc: "application/msword",
21
- docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
22
- gif: "image/gif",
23
- html: "text/html",
24
- jpeg: "image/jpeg",
25
- jpg: "image/jpeg",
26
- json: "application/json",
27
- md: "text/markdown",
28
- mp3: "audio/mpeg",
29
- mp4: "video/mp4",
30
- pdf: "application/pdf",
31
- png: "image/png",
32
- txt: "text/plain",
33
- wav: "audio/wav",
34
- webp: "image/webp",
35
- xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
36
- zip: "application/zip",
37
- };
38
- function mediaTypeFor(path, override) {
39
- if (override)
40
- return override;
41
- return (MEDIA_TYPES[extname(path).slice(1).toLowerCase()] ??
42
- "application/octet-stream");
43
- }
44
- async function fileBody(path, sizeBytes) {
45
- try {
46
- return await openAsBlob(path);
47
- }
48
- catch {
49
- // Runtimes without fs.openAsBlob fall back to buffering; refuse to
50
- // buffer files that would strain the host process.
51
- if (sizeBytes > 512 * 1024 * 1024) {
52
- throw new Error("This runtime cannot stream uploads and the file is too large to buffer.");
53
- }
54
- return await readFile(path);
55
- }
56
- }
57
16
  function linkSchema() {
58
17
  return t.Object({
59
18
  targetType: enumSchema("Link target type", OBJECT_LINK_TARGET),
@@ -108,21 +67,16 @@ export function registerObjectActions(rl) {
108
67
  }, STRICT_OBJECT),
109
68
  async execute(input, ctx) {
110
69
  const fields = input;
111
- const contentType = mediaTypeFor(fields.path, fields.contentType);
112
- const file = await stat(fields.path);
113
- if (!file.isFile())
114
- throw new Error(`${fields.path} is not a file`);
115
- if (file.size === 0)
116
- throw new Error("File is empty");
117
- if (file.size > MAX_OBJECT_BYTES) {
118
- throw new Error(`File is larger than the ${MAX_OBJECT_BYTES}-byte service limit`);
119
- }
70
+ const contentType = fields.contentType ??
71
+ mediaTypeFromPath(fields.path, GENERAL_MEDIA_TYPES) ??
72
+ "application/octet-stream";
73
+ const sizeBytes = await statUploadFile(fields.path, SIGNED_UPLOAD_MAX_BYTES);
120
74
  const created = await request(ctx, `${OBJECTS_BASE}/objects`, {
121
75
  method: "POST",
122
76
  // The API key is the tenant authority; no organizationId is sent.
123
77
  body: JSON.stringify({
124
78
  contentType,
125
- sizeBytes: file.size,
79
+ sizeBytes,
126
80
  filename: fields.filename ?? basename(fields.path),
127
81
  workspaceId: fields.workspaceId,
128
82
  sessionId: fields.sessionId,
@@ -130,18 +84,7 @@ export function registerObjectActions(rl) {
130
84
  metadata: fields.metadata,
131
85
  }),
132
86
  });
133
- const uploadHeaders = new Headers(created.upload.headers);
134
- if (!uploadHeaders.has("content-type")) {
135
- uploadHeaders.set("content-type", contentType);
136
- }
137
- const upload = await fetch(created.upload.url, {
138
- method: created.upload.method,
139
- headers: uploadHeaders,
140
- body: await fileBody(fields.path, file.size),
141
- });
142
- if (!upload.ok) {
143
- throw new Error(`Object upload failed: HTTP ${upload.status}`);
144
- }
87
+ await putThroughGrant(created.upload, fields.path, contentType, sizeBytes, "Object upload");
145
88
  return request(ctx, `${OBJECTS_BASE}/objects/${pathSegment(created.object.id)}/complete`, { method: "POST" });
146
89
  },
147
90
  });
@@ -155,13 +98,7 @@ export function registerObjectActions(rl) {
155
98
  ...provenanceFields,
156
99
  }, STRICT_OBJECT),
157
100
  async execute(input, ctx) {
158
- const params = new URLSearchParams();
159
- for (const [key, value] of Object.entries(input)) {
160
- if (value !== undefined)
161
- params.set(key, String(value));
162
- }
163
- const query = params.toString();
164
- const body = await request(ctx, `${OBJECTS_BASE}/objects${query ? `?${query}` : ""}`);
101
+ const body = await request(ctx, withQuery(`${OBJECTS_BASE}/objects`, listParams(input)));
165
102
  return body.objects;
166
103
  },
167
104
  });
@@ -1,11 +1,17 @@
1
- import { readFile, stat } from "node:fs/promises";
2
- import { extname } from "node:path";
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename, extname } from "node:path";
3
3
  import * as t from "typebox";
4
4
  import { request, STRICT_OBJECT } from "../../_shared/shiftCloud.js";
5
+ import { putThroughGrant, SIGNED_UPLOAD_MAX_BYTES, statUploadFile, } from "../../_shared/shiftUpload.js";
5
6
  /**
6
7
  * Shift OCR — text and structured-field extraction from images and
7
8
  * PDFs through the Shift cloud OCR service. The API key is the tenant
8
9
  * authority; no organization ID is ever sent (SHFT-852).
10
+ *
11
+ * Local files route by size: small ones inline as base64 data URLs
12
+ * (one round-trip); larger ones ride the signed-grant arc into the
13
+ * organization's durable object bucket and extract by objectId
14
+ * (SHFT-924), so bytes go to S3, never through the API.
9
15
  */
10
16
  const IMAGE_TYPES = {
11
17
  avif: "image/avif",
@@ -20,10 +26,11 @@ const DOCUMENT_TYPES = {
20
26
  };
21
27
  /**
22
28
  * Raw-file ceiling for inlining as a base64 data URL. Base64 inflates
23
- * by 4/3, so 20 MiB raw stays well inside provider request limits.
29
+ * by 4/3, so 20 MiB raw stays well inside provider request limits;
30
+ * anything larger uploads to the object bucket instead.
24
31
  */
25
- const MAX_FILE_BYTES = 20 * 1024 * 1024;
26
- async function documentFromPath(path) {
32
+ const MAX_INLINE_BYTES = 20 * 1024 * 1024;
33
+ async function documentFromPath(ctx, path) {
27
34
  const extension = extname(path).slice(1).toLowerCase();
28
35
  const imageType = IMAGE_TYPES[extension];
29
36
  const documentType = DOCUMENT_TYPES[extension];
@@ -33,19 +40,27 @@ async function documentFromPath(path) {
33
40
  ...Object.keys(DOCUMENT_TYPES),
34
41
  ].join(", ")}`);
35
42
  }
36
- const file = await stat(path);
37
- if (!file.isFile())
38
- throw new Error(`${path} is not a file`);
39
- if (file.size === 0)
40
- throw new Error("File is empty");
41
- if (file.size > MAX_FILE_BYTES) {
42
- throw new Error(`File is ${file.size} bytes; inline OCR uploads are capped at ${MAX_FILE_BYTES} bytes. Host the file and pass url instead.`);
43
+ const mediaType = (imageType ?? documentType);
44
+ const sizeBytes = await statUploadFile(path, SIGNED_UPLOAD_MAX_BYTES);
45
+ if (sizeBytes <= MAX_INLINE_BYTES) {
46
+ const url = `data:${mediaType};base64,${(await readFile(path)).toString("base64")}`;
47
+ return imageType
48
+ ? { type: "image", url }
49
+ : { type: "document", url, name: basename(path) };
43
50
  }
44
- const mediaType = imageType ?? documentType;
45
- const url = `data:${mediaType};base64,${(await readFile(path)).toString("base64")}`;
46
- return imageType
47
- ? { type: "image", url }
48
- : { type: "document", url, name: path.replace(/.*\//, "") };
51
+ // Large file: into the durable bucket through a signed grant, then
52
+ // extract by reference — the object persists as provenance.
53
+ const created = await request(ctx, "/v1/services/objects/objects", {
54
+ method: "POST",
55
+ body: JSON.stringify({
56
+ contentType: mediaType,
57
+ sizeBytes,
58
+ filename: basename(path),
59
+ }),
60
+ });
61
+ await putThroughGrant(created.upload, path, mediaType, sizeBytes);
62
+ await request(ctx, `/v1/services/objects/objects/${created.object.id}/complete`, { method: "POST" });
63
+ return { type: "object", objectId: created.object.id };
49
64
  }
50
65
  function documentFromUrl(url, kind) {
51
66
  if (kind)
@@ -75,9 +90,10 @@ export default function shiftOcr(rl) {
75
90
  rl.registerAction("ocr.extract", {
76
91
  access: "write",
77
92
  description: "Extract markdown text — and optionally schema-constrained JSON — " +
78
- "from an image or PDF. Pass a local file path (inlined as a data " +
79
- "URL) or an https URL. Provide schema to get structured fields " +
80
- "back instead of parsing the text yourself.",
93
+ "from an image or PDF. Pass a local file path (small files inline; " +
94
+ "large ones upload to the org's object bucket automatically), an " +
95
+ "https URL, or the objectId of a stored object. Provide schema to " +
96
+ "get structured fields back instead of parsing the text yourself.",
81
97
  inputSchema: t.Object({
82
98
  path: t.Optional(t.String({
83
99
  minLength: 1,
@@ -89,10 +105,26 @@ export default function shiftOcr(rl) {
89
105
  pattern: "\\S",
90
106
  description: "https or data URL of the image or PDF",
91
107
  })),
108
+ objectId: t.Optional(t.String({
109
+ minLength: 1,
110
+ pattern: "\\S",
111
+ description: "ID of a ready object in the organization's durable bucket",
112
+ })),
92
113
  kind: t.Optional(t.Union([t.Literal("image"), t.Literal("document")], {
93
114
  description: "Override for URLs whose extension does not reveal the type. " +
94
115
  "Inferred from the extension otherwise.",
95
116
  })),
117
+ provider: t.Optional(t.String({
118
+ minLength: 1,
119
+ pattern: "\\S",
120
+ description: "Provider ID from ocr.providers. Defaults to the first " +
121
+ "catalog provider.",
122
+ })),
123
+ model: t.Optional(t.String({
124
+ minLength: 1,
125
+ pattern: "\\S",
126
+ description: "Model override. Defaults to the provider's defaultModel.",
127
+ })),
96
128
  pages: t.Optional(t.String({
97
129
  minLength: 1,
98
130
  pattern: "^\\d+(-\\d+)?(,\\d+(-\\d+)?)*$",
@@ -114,19 +146,24 @@ export default function shiftOcr(rl) {
114
146
  }, STRICT_OBJECT),
115
147
  async execute(input, ctx) {
116
148
  const fields = input;
117
- if (!fields.path === !fields.url) {
118
- throw new Error("Provide exactly one of path or url");
149
+ const sources = [fields.path, fields.url, fields.objectId].filter((value) => value !== undefined);
150
+ if (sources.length !== 1) {
151
+ throw new Error("Provide exactly one of path, url, or objectId");
119
152
  }
120
153
  if (fields.prompt && !fields.schema) {
121
154
  throw new Error("prompt requires schema");
122
155
  }
123
- const document = fields.path
124
- ? await documentFromPath(fields.path)
125
- : documentFromUrl(fields.url, fields.kind);
156
+ const document = fields.objectId
157
+ ? { type: "object", objectId: fields.objectId }
158
+ : fields.path
159
+ ? await documentFromPath(ctx, fields.path)
160
+ : documentFromUrl(fields.url, fields.kind);
126
161
  return await request(ctx, "/v1/services/ocr/extract", {
127
162
  method: "POST",
128
163
  body: JSON.stringify({
129
164
  document,
165
+ ...(fields.provider ? { provider: fields.provider } : {}),
166
+ ...(fields.model ? { model: fields.model } : {}),
130
167
  ...(fields.pages ? { pages: fields.pages } : {}),
131
168
  ...(fields.schema
132
169
  ? {
@@ -0,0 +1,19 @@
1
+ import * as t from "typebox";
2
+ import { registerPageActions } from "./pages.js";
3
+ /**
4
+ * Shift Pages — hosted HTML pages in the Shift cloud: draft, publish,
5
+ * archive, share with invited viewers, and resolve authenticated
6
+ * render URLs. The API key is the tenant authority; the cloud derives
7
+ * the organization from it (SHFT-852), so no org ID is sent.
8
+ */
9
+ export default function shiftPages(rl) {
10
+ rl.setName("shiftPages");
11
+ rl.setVersion("0.1.0");
12
+ rl.setConnectionSchema(t.Object({
13
+ apiKey: t.String({
14
+ description: "Shift Labs API key",
15
+ env: "SHIFT_LABS_API_KEY",
16
+ }),
17
+ }));
18
+ registerPageActions(rl);
19
+ }
@@ -1,11 +1,18 @@
1
1
  import * as t from "typebox";
2
- import { enumSchema, idSchema, PAGE_STATUS, PAGE_VISIBILITY, pageRenderUrl, pathSegment, request, STRICT_OBJECT, timestampSchema, } from "./shared.js";
2
+ import { enumSchema, idSchema, listParams, PAGE_STATUS, PAGE_TYPE, PAGE_VISIBILITY, pageRenderUrl, pathSegment, request, STRICT_OBJECT, timestampSchema, withQuery, } from "./shared.js";
3
3
  const PageSlug = t.String({
4
4
  minLength: 1,
5
5
  maxLength: 120,
6
6
  pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$",
7
7
  description: "Lowercase kebab-case page slug",
8
8
  });
9
+ const OriginUrl = t.String({
10
+ minLength: 1,
11
+ format: "uri",
12
+ pattern: "^https://",
13
+ description: "HTTPS origin to proxy behind SSO (protected_origin pages only). " +
14
+ "Localhost and private IP hosts are rejected.",
15
+ });
9
16
  export function registerPageActions(rl) {
10
17
  rl.registerAction("page.list", {
11
18
  access: "read",
@@ -19,13 +26,7 @@ export function registerPageActions(rl) {
19
26
  })),
20
27
  }, STRICT_OBJECT),
21
28
  async execute(input, ctx) {
22
- const fields = (input ?? {});
23
- const params = new URLSearchParams();
24
- if (fields.status)
25
- params.set("status", String(fields.status));
26
- if (fields.limit)
27
- params.set("limit", String(fields.limit));
28
- const body = await request(ctx, `/v1/pages?${params}`);
29
+ const body = await request(ctx, withQuery("/v1/pages", listParams(input)));
29
30
  return body.pages;
30
31
  },
31
32
  });
@@ -39,9 +40,23 @@ export function registerPageActions(rl) {
39
40
  return body.page;
40
41
  },
41
42
  });
43
+ rl.registerAction("page.vexArtifacts", {
44
+ access: "read",
45
+ description: "List the shareable Vex artifacts on a managed deployment, sorted by " +
46
+ "title, each carrying the workspace it belongs to. Pass an artifact's " +
47
+ "ID to page.create with type vex_artifact to publish it. The " +
48
+ "deployment must belong to the API key's organization.",
49
+ inputSchema: t.Object({ deploymentId: idSchema("Managed Vex deployment ID") }, STRICT_OBJECT),
50
+ async execute(input, ctx) {
51
+ const body = await request(ctx, withQuery("/v1/pages/vex-artifacts", listParams(input)));
52
+ return body.artifacts;
53
+ },
54
+ });
42
55
  rl.registerAction("page.create", {
43
56
  access: "write",
44
- description: "Create a draft hosted HTML page. Agents can publish it with page.publish.",
57
+ description: "Create a draft page and publish it with page.publish. hosted_html " +
58
+ "pages carry their own HTML; vex_artifact pages render a live Vex " +
59
+ "artifact (pass deploymentId and artifactId from page.vexArtifacts).",
45
60
  inputSchema: t.Object({
46
61
  slug: PageSlug,
47
62
  title: t.String({
@@ -50,14 +65,19 @@ export function registerPageActions(rl) {
50
65
  pattern: "\\S",
51
66
  description: "Page title",
52
67
  }),
68
+ type: t.Optional(enumSchema("Page type", PAGE_TYPE)),
53
69
  html: t.Optional(t.String({
54
70
  maxLength: 1_000_000,
55
- description: "Hosted HTML content",
71
+ description: "Hosted HTML content (hosted_html pages only)",
56
72
  })),
73
+ originUrl: t.Optional(OriginUrl),
74
+ deploymentId: t.Optional(idSchema("Managed Vex deployment ID (vex_artifact pages only)")),
75
+ artifactId: t.Optional(idSchema("Vex artifact ID (vex_artifact pages only)")),
57
76
  visibility: t.Optional(enumSchema("Page visibility", PAGE_VISIBILITY)),
58
77
  }, STRICT_OBJECT),
59
78
  async execute(input, ctx) {
60
79
  const fields = input;
80
+ assertPageContent(fields);
61
81
  const body = await request(ctx, "/v1/pages", {
62
82
  method: "POST",
63
83
  body: JSON.stringify({
@@ -71,7 +91,9 @@ export function registerPageActions(rl) {
71
91
  });
72
92
  rl.registerAction("page.update", {
73
93
  access: "write",
74
- description: "Update a hosted HTML page's slug, title, visibility, or HTML.",
94
+ description: "Update a page's slug, title, visibility, HTML, or — for vex_artifact " +
95
+ "pages — which deployment and artifact it renders. A page's type is " +
96
+ "fixed at creation.",
75
97
  inputSchema: t.Object({
76
98
  id: idSchema("Page ID"),
77
99
  slug: t.Optional(PageSlug),
@@ -84,8 +106,11 @@ export function registerPageActions(rl) {
84
106
  visibility: t.Optional(enumSchema("Page visibility", PAGE_VISIBILITY)),
85
107
  html: t.Optional(t.String({
86
108
  maxLength: 1_000_000,
87
- description: "Hosted HTML content",
109
+ description: "Hosted HTML content (hosted_html pages only)",
88
110
  })),
111
+ originUrl: t.Optional(OriginUrl),
112
+ deploymentId: t.Optional(idSchema("Managed Vex deployment ID (vex_artifact pages only)")),
113
+ artifactId: t.Optional(idSchema("Vex artifact ID (vex_artifact pages only)")),
89
114
  }, STRICT_OBJECT),
90
115
  async execute(input, ctx) {
91
116
  const { id, ...fields } = input;
@@ -98,7 +123,8 @@ export function registerPageActions(rl) {
98
123
  });
99
124
  rl.registerAction("page.publish", {
100
125
  access: "write",
101
- description: "Publish a Shift Labs hosted HTML page.",
126
+ description: "Publish a draft page, making it reachable at its render URL by the " +
127
+ "organization (or by invited viewers when visibility is invited).",
102
128
  inputSchema: t.Object({ id: idSchema("Page ID") }, STRICT_OBJECT),
103
129
  async execute(input, ctx) {
104
130
  const { id } = input;
@@ -119,10 +145,10 @@ export function registerPageActions(rl) {
119
145
  rl.registerAction("page.shares", {
120
146
  access: "read",
121
147
  description: "List shares for a Shift Labs page.",
122
- inputSchema: t.Object({ pageId: idSchema("Page ID") }, STRICT_OBJECT),
148
+ inputSchema: t.Object({ id: idSchema("Page ID") }, STRICT_OBJECT),
123
149
  async execute(input, ctx) {
124
- const { pageId } = input;
125
- const body = await request(ctx, `/v1/pages/${pathSegment(pageId)}/shares`);
150
+ const { id } = input;
151
+ const body = await request(ctx, `/v1/pages/${pathSegment(id)}/shares`);
126
152
  return body.shares;
127
153
  },
128
154
  });
@@ -130,7 +156,7 @@ export function registerPageActions(rl) {
130
156
  access: "write",
131
157
  description: "Create a viewer share for a Shift Labs page.",
132
158
  inputSchema: t.Object({
133
- pageId: idSchema("Page ID"),
159
+ id: idSchema("Page ID"),
134
160
  email: t.String({
135
161
  minLength: 1,
136
162
  format: "email",
@@ -139,8 +165,8 @@ export function registerPageActions(rl) {
139
165
  expiresAt: t.Optional(timestampSchema("Optional ISO-8601 expiration")),
140
166
  }, STRICT_OBJECT),
141
167
  async execute(input, ctx) {
142
- const { pageId, ...fields } = input;
143
- const body = await request(ctx, `/v1/pages/${pathSegment(String(pageId))}/shares`, {
168
+ const { id, ...fields } = input;
169
+ const body = await request(ctx, `/v1/pages/${pathSegment(String(id))}/shares`, {
144
170
  method: "POST",
145
171
  body: JSON.stringify(fields),
146
172
  });
@@ -162,13 +188,38 @@ export function registerPageActions(rl) {
162
188
  rl.registerAction("page.renderUrl", {
163
189
  access: "read",
164
190
  description: "Return the authenticated render URL for a page.",
165
- inputSchema: t.Object({ pageId: idSchema("Page ID") }, STRICT_OBJECT),
191
+ inputSchema: t.Object({ id: idSchema("Page ID") }, STRICT_OBJECT),
166
192
  async execute(input, ctx) {
167
- const { pageId } = input;
168
- const body = await request(ctx, `/v1/pages/${pathSegment(pageId)}`);
193
+ const { id } = input;
194
+ const body = await request(ctx, `/v1/pages/${pathSegment(id)}`);
169
195
  return {
170
196
  url: pageRenderUrl(body.page.organizationId, body.page.slug),
171
197
  };
172
198
  },
173
199
  });
174
200
  }
201
+ /**
202
+ * The content rules the API enforces per page type, checked before the
203
+ * round-trip so a caller gets the reason rather than a bare 400. Each
204
+ * type owns exactly one content field.
205
+ */
206
+ function assertPageContent(fields) {
207
+ const type = fields.type ?? "hosted_html";
208
+ const referencesArtifact = fields.deploymentId !== undefined || fields.artifactId !== undefined;
209
+ if (type !== "vex_artifact" && referencesArtifact) {
210
+ throw new Error("Only vex_artifact pages may reference a deployment or artifact");
211
+ }
212
+ if (type !== "protected_origin" && fields.originUrl !== undefined) {
213
+ throw new Error("Only protected_origin pages may set an originUrl");
214
+ }
215
+ if (type !== "hosted_html" && fields.html !== undefined) {
216
+ throw new Error("Only hosted_html pages may carry html");
217
+ }
218
+ if (type === "vex_artifact" &&
219
+ (fields.deploymentId === undefined || fields.artifactId === undefined)) {
220
+ throw new Error("vex_artifact pages require both deploymentId and artifactId");
221
+ }
222
+ if (type === "protected_origin" && fields.originUrl === undefined) {
223
+ throw new Error("protected_origin pages require an originUrl");
224
+ }
225
+ }
@@ -0,0 +1,12 @@
1
+ import { baseUrl, pathSegment } from "../../_shared/shiftCloud.js";
2
+ export { enumSchema, idSchema, listParams, pathSegment, request, STRICT_OBJECT, timestampSchema, withQuery, } from "../../_shared/shiftCloud.js";
3
+ export const PAGE_STATUS = ["draft", "published", "archived"];
4
+ export const PAGE_TYPE = [
5
+ "hosted_html",
6
+ "protected_origin",
7
+ "vex_artifact",
8
+ ];
9
+ export const PAGE_VISIBILITY = ["org", "invited"];
10
+ export function pageRenderUrl(organizationId, slug) {
11
+ return new URL(`/pages/${pathSegment(organizationId)}/${pathSegment(slug)}`, baseUrl()).toString();
12
+ }
@@ -0,0 +1,20 @@
1
+ import * as t from "typebox";
2
+ import { registerTranscriptionActions } from "./transcription.js";
3
+ /**
4
+ * Shift Transcription — audio and video transcription through the
5
+ * Shift cloud: upload media, run jobs (optionally diarized), and read
6
+ * transcripts as text, subtitles, or timestamped JSON. The API key is
7
+ * the tenant authority; the cloud derives the organization from it
8
+ * (SHFT-852), so no org ID is sent.
9
+ */
10
+ export default function shiftTranscription(rl) {
11
+ rl.setName("shiftTranscription");
12
+ rl.setVersion("0.1.0");
13
+ rl.setConnectionSchema(t.Object({
14
+ apiKey: t.String({
15
+ description: "Shift Labs API key",
16
+ env: "SHIFT_LABS_API_KEY",
17
+ }),
18
+ }));
19
+ registerTranscriptionActions(rl);
20
+ }
@@ -0,0 +1,3 @@
1
+ export { enumSchema, pathSegment, request, } from "../../_shared/shiftCloud.js";
2
+ export const TRANSCRIPTION_LANGUAGE = ["auto", "en", "he"];
3
+ export const TRANSCRIPT_FORMAT = ["txt", "srt", "vtt", "json"];
@@ -1,50 +1,10 @@
1
- import { openAsBlob } from "node:fs";
2
- import { readFile, stat } from "node:fs/promises";
3
- import { extname } from "node:path";
4
1
  import * as t from "typebox";
2
+ import { AUDIO_VIDEO_MEDIA_TYPES, putThroughGrant, requireMediaType, SIGNED_UPLOAD_MAX_BYTES, statUploadFile, } from "../../_shared/shiftUpload.js";
5
3
  import { enumSchema, pathSegment, request, TRANSCRIPT_FORMAT, TRANSCRIPTION_LANGUAGE, } from "./shared.js";
6
- const MEDIA_TYPES = {
7
- aac: "audio/aac",
8
- flac: "audio/flac",
9
- m4a: "audio/m4a",
10
- mov: "video/quicktime",
11
- mp3: "audio/mpeg",
12
- mp4: "video/mp4",
13
- mpeg: "video/mpeg",
14
- mpg: "video/mpeg",
15
- oga: "audio/ogg",
16
- ogg: "audio/ogg",
17
- opus: "audio/ogg",
18
- wav: "audio/wav",
19
- wave: "audio/wav",
20
- webm: "video/webm",
21
- };
22
- /** Mirrors the public service contract's 5 GiB source-object ceiling. */
23
- const MAX_MEDIA_BYTES = 5 * 1024 * 1024 * 1024;
24
4
  /** Transcript previews returned into the sandbox are capped, not truncated silently. */
25
5
  const MAX_TRANSCRIPT_BYTES = 2 * 1024 * 1024;
26
6
  const STRICT_OBJECT = { additionalProperties: false };
27
7
  const Id = t.String({ minLength: 1, pattern: "\\S" });
28
- function mediaTypeFor(path) {
29
- const mediaType = MEDIA_TYPES[extname(path).slice(1).toLowerCase()];
30
- if (!mediaType) {
31
- throw new Error(`Unsupported media extension for ${path}. Supported: ${Object.keys(MEDIA_TYPES).join(", ")}`);
32
- }
33
- return mediaType;
34
- }
35
- async function fileBody(path, sizeBytes) {
36
- try {
37
- return await openAsBlob(path);
38
- }
39
- catch {
40
- // Runtimes without fs.openAsBlob fall back to buffering; refuse to
41
- // buffer files that would strain the host process.
42
- if (sizeBytes > 512 * 1024 * 1024) {
43
- throw new Error("This runtime cannot stream uploads and the file is too large to buffer.");
44
- }
45
- return await readFile(path);
46
- }
47
- }
48
8
  export function registerTranscriptionActions(rl) {
49
9
  rl.registerAction("transcription.transcribe", {
50
10
  access: "write",
@@ -68,6 +28,9 @@ export function registerTranscriptionActions(rl) {
68
28
  pattern: "\\S",
69
29
  description: "Label shown in Shift Cloud",
70
30
  })),
31
+ metadata: t.Optional(t.Record(t.String(), t.Unknown(), {
32
+ description: "Small caller-defined metadata object stored on the job",
33
+ })),
71
34
  waitSeconds: t.Optional(t.Number({
72
35
  minimum: 0,
73
36
  maximum: 120,
@@ -76,35 +39,17 @@ export function registerTranscriptionActions(rl) {
76
39
  }, STRICT_OBJECT),
77
40
  async execute(input, ctx) {
78
41
  const fields = input;
79
- const contentType = mediaTypeFor(fields.path);
80
- const file = await stat(fields.path);
81
- if (!file.isFile())
82
- throw new Error(`${fields.path} is not a file`);
83
- if (file.size === 0)
84
- throw new Error("Media file is empty");
85
- if (file.size > MAX_MEDIA_BYTES) {
86
- throw new Error(`Media file is larger than the ${MAX_MEDIA_BYTES}-byte service limit`);
87
- }
42
+ const contentType = requireMediaType(fields.path, AUDIO_VIDEO_MEDIA_TYPES);
43
+ const sizeBytes = await statUploadFile(fields.path, SIGNED_UPLOAD_MAX_BYTES, "Media file");
88
44
  const created = await request(ctx, "/v1/services/transcription/assets", {
89
45
  method: "POST",
90
46
  // The API key is the tenant authority; no organizationId is sent.
91
47
  body: JSON.stringify({
92
48
  contentType,
93
- sizeBytes: file.size,
49
+ sizeBytes,
94
50
  }),
95
51
  });
96
- const uploadHeaders = new Headers(created.upload.headers);
97
- if (!uploadHeaders.has("content-type")) {
98
- uploadHeaders.set("content-type", contentType);
99
- }
100
- const upload = await fetch(created.upload.url, {
101
- method: created.upload.method,
102
- headers: uploadHeaders,
103
- body: await fileBody(fields.path, file.size),
104
- });
105
- if (!upload.ok) {
106
- throw new Error(`Media upload failed: HTTP ${upload.status}`);
107
- }
52
+ await putThroughGrant(created.upload, fields.path, contentType, sizeBytes, "Media upload");
108
53
  await request(ctx, `/v1/services/transcription/assets/${pathSegment(created.asset.id)}/complete`, { method: "POST" });
109
54
  const job = await request(ctx, "/v1/services/transcription/jobs", {
110
55
  method: "POST",
@@ -113,6 +58,7 @@ export function registerTranscriptionActions(rl) {
113
58
  language: fields.language,
114
59
  diarize: fields.diarize,
115
60
  name: fields.name,
61
+ metadata: fields.metadata,
116
62
  }),
117
63
  });
118
64
  if (!fields.waitSeconds)
@@ -151,10 +97,10 @@ export function registerTranscriptionActions(rl) {
151
97
  rl.registerAction("transcription.artifact.list", {
152
98
  access: "read",
153
99
  description: "List the available transcript artifacts for a job.",
154
- inputSchema: t.Object({ jobId: Id }, STRICT_OBJECT),
100
+ inputSchema: t.Object({ id: Id }, STRICT_OBJECT),
155
101
  async execute(input, ctx) {
156
- const { jobId } = input;
157
- const body = await request(ctx, `/v1/services/transcription/jobs/${pathSegment(jobId)}/artifacts`);
102
+ const { id } = input;
103
+ const body = await request(ctx, `/v1/services/transcription/jobs/${pathSegment(id)}/artifacts`);
158
104
  return body.artifacts;
159
105
  },
160
106
  });
@@ -164,14 +110,14 @@ export function registerTranscriptionActions(rl) {
164
110
  "return subtitles; json returns the full timestamped document. Set " +
165
111
  "downloadOnly=true to return the short-lived signed download grant.",
166
112
  inputSchema: t.Object({
167
- jobId: Id,
113
+ id: Id,
168
114
  format: t.Optional(enumSchema("Transcript format", [...TRANSCRIPT_FORMAT])),
169
115
  downloadOnly: t.Optional(t.Boolean({ description: "Return a signed download grant only" })),
170
116
  }, STRICT_OBJECT),
171
117
  async execute(input, ctx) {
172
118
  const fields = input;
173
119
  const format = fields.format ?? "txt";
174
- const grant = await request(ctx, `/v1/services/transcription/jobs/${pathSegment(fields.jobId)}/artifacts/${pathSegment(format)}/download`, { method: "POST" });
120
+ const grant = await request(ctx, `/v1/services/transcription/jobs/${pathSegment(fields.id)}/artifacts/${pathSegment(format)}/download`, { method: "POST" });
175
121
  if (fields.downloadOnly ||
176
122
  grant.artifact.sizeBytes > MAX_TRANSCRIPT_BYTES) {
177
123
  return { format, ...grant };
@@ -1,16 +1,17 @@
1
1
  import * as t from "typebox";
2
2
  import { registerIssueViewActions } from "./issue-views.js";
3
3
  import { registerIssueActions } from "./issues.js";
4
- import { registerObjectActions } from "./objects.js";
5
- import { registerPageActions } from "./pages.js";
6
4
  import { registerProjectActions } from "./projects.js";
7
- import { registerTranscriptionActions } from "./transcription.js";
8
- export default function shiftLabs(rl) {
9
- rl.setName("shiftLabs");
5
+ /**
6
+ * Shift Work — the work-tracking domain of the Shift cloud: Projects,
7
+ * Issues (with dependencies and comments), and saved Issue Views.
8
+ * The API key is the tenant authority for every action; the cloud
9
+ * derives the organization from it (SHFT-852), so no org ID is sent.
10
+ */
11
+ export default function shiftWork(rl) {
12
+ rl.setName("shiftWork");
10
13
  rl.setVersion("0.1.0");
11
14
  rl.setConnectionSchema(t.Object({
12
- // The API key is the tenant authority for every action; the cloud
13
- // derives the organization from it (SHFT-852), so no org ID is needed.
14
15
  apiKey: t.String({
15
16
  description: "Shift Labs API key",
16
17
  env: "SHIFT_LABS_API_KEY",
@@ -19,7 +20,4 @@ export default function shiftLabs(rl) {
19
20
  registerProjectActions(rl);
20
21
  registerIssueActions(rl);
21
22
  registerIssueViewActions(rl);
22
- registerPageActions(rl);
23
- registerTranscriptionActions(rl);
24
- registerObjectActions(rl);
25
23
  }
@@ -1,5 +1,5 @@
1
1
  import * as t from "typebox";
2
- import { cursorSchema, enumSchema, ISSUE_PRIORITY, ISSUE_STATUS, ISSUE_VIEW_GROUP, ISSUE_VIEW_LAYOUT, ISSUE_VIEW_SORT, ISSUE_VIEW_VISIBILITY, idSchema, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, } from "./shared.js";
2
+ import { cursorSchema, enumSchema, ISSUE_PRIORITY, ISSUE_STATUS, ISSUE_VIEW_GROUP, ISSUE_VIEW_LAYOUT, ISSUE_VIEW_SORT, ISSUE_VIEW_VISIBILITY, idSchema, listParams, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, withQuery, } from "./shared.js";
3
3
  const viewFields = {
4
4
  name: t.String({
5
5
  minLength: 1,
@@ -143,18 +143,6 @@ export function registerIssueViewActions(rl) {
143
143
  },
144
144
  });
145
145
  }
146
- function listParams(input) {
147
- const params = new URLSearchParams();
148
- for (const [key, value] of Object.entries((input ?? {}))) {
149
- if (value !== undefined)
150
- params.set(key, String(value));
151
- }
152
- return params;
153
- }
154
- function withQuery(path, params) {
155
- const query = params.toString();
156
- return query ? `${path}?${query}` : path;
157
- }
158
146
  function assertDateOrder(startAfter, dueBefore) {
159
147
  if (typeof startAfter === "string" &&
160
148
  typeof dueBefore === "string" &&
@@ -1,5 +1,30 @@
1
1
  import * as t from "typebox";
2
- import { cursorSchema, enumSchema, ISSUE_PRIORITY, ISSUE_SOURCE, ISSUE_STATUS, idSchema, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, } from "./shared.js";
2
+ import { cursorSchema, enumSchema, ISSUE_PRIORITY, ISSUE_SOURCE, ISSUE_STATUS, idSchema, listParams, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, withQuery, } from "./shared.js";
3
+ const issueFields = {
4
+ title: t.String({
5
+ minLength: 1,
6
+ maxLength: 160,
7
+ pattern: "\\S",
8
+ description: "Issue title",
9
+ }),
10
+ description: t.Optional(t.String({ maxLength: 20_000, description: "Issue description" })),
11
+ projectId: t.Optional(idSchema("Project ID")),
12
+ parentIssueId: t.Optional(idSchema("Parent Issue ID")),
13
+ sortOrder: t.Optional(t.Integer({ minimum: 0 })),
14
+ status: t.Optional(enumSchema("Issue status", ISSUE_STATUS)),
15
+ priority: t.Optional(enumSchema("Issue priority", ISSUE_PRIORITY)),
16
+ assigneeUserId: t.Optional(idSchema("Assignee user ID")),
17
+ startAt: t.Optional(timestampSchema("ISO-8601 start timestamp")),
18
+ dueAt: t.Optional(timestampSchema("ISO-8601 due timestamp")),
19
+ deploymentId: t.Optional(idSchema("Deployment ID")),
20
+ workspaceId: t.Optional(idSchema("Workspace ID")),
21
+ sessionId: t.Optional(idSchema("Session ID")),
22
+ traceId: t.Optional(idSchema("Trace ID")),
23
+ fingerprint: t.Optional(t.String({ minLength: 1, maxLength: 512, pattern: "\\S" })),
24
+ labels: t.Optional(t.Array(t.String({ minLength: 1, maxLength: 64, pattern: "\\S" }))),
25
+ metadata: t.Optional(t.Record(t.String(), t.Unknown(), { description: "Issue metadata" })),
26
+ source: t.Optional(enumSchema("Issue source", ISSUE_SOURCE)),
27
+ };
3
28
  const issueListFields = {
4
29
  status: t.Optional(enumSchema("Issue status", ISSUE_STATUS)),
5
30
  projectId: t.Optional(idSchema("Project ID")),
@@ -14,25 +39,22 @@ const issueListFields = {
14
39
  description: "Max results, default 50",
15
40
  })),
16
41
  };
17
- function issueListParams(input) {
18
- const params = new URLSearchParams();
19
- for (const [key, value] of Object.entries((input ?? {}))) {
20
- if (value !== undefined)
21
- params.set(key, String(value));
22
- }
23
- return params;
24
- }
25
- function withQuery(path, params) {
26
- const query = params.toString();
27
- return query ? `${path}?${query}` : path;
28
- }
42
+ const eventListFields = {
43
+ id: idSchema("Issue ID"),
44
+ cursor: t.Optional(cursorSchema()),
45
+ limit: t.Optional(t.Integer({
46
+ minimum: 1,
47
+ maximum: 100,
48
+ description: "Max results, default 50",
49
+ })),
50
+ };
29
51
  export function registerIssueActions(rl) {
30
52
  rl.registerAction("issue.list", {
31
53
  access: "read",
32
54
  description: "List the first page of Shift Labs Issues for the API key's organization.",
33
55
  inputSchema: t.Object(issueListFields, STRICT_OBJECT),
34
56
  async execute(input, ctx) {
35
- const body = await request(ctx, `/v1/issues?${issueListParams(input)}`);
57
+ const body = await request(ctx, `/v1/issues?${listParams(input)}`);
36
58
  return body.issues;
37
59
  },
38
60
  });
@@ -41,7 +63,7 @@ export function registerIssueActions(rl) {
41
63
  description: "List a cursor-paginated page of Shift Labs Issues.",
42
64
  inputSchema: t.Object(issueListFields, STRICT_OBJECT),
43
65
  async execute(input, ctx) {
44
- return request(ctx, `/v1/issues?${issueListParams(input)}`);
66
+ return request(ctx, `/v1/issues?${listParams(input)}`);
45
67
  },
46
68
  });
47
69
  rl.registerAction("issue.get", {
@@ -57,38 +79,10 @@ export function registerIssueActions(rl) {
57
79
  rl.registerAction("issue.create", {
58
80
  access: "write",
59
81
  description: "Create an Issue. Leave projectId empty for inbox work; parentIssueId requires a Project.",
60
- inputSchema: t.Object({
61
- title: t.String({
62
- minLength: 1,
63
- maxLength: 160,
64
- pattern: "\\S",
65
- description: "Issue title",
66
- }),
67
- description: t.Optional(t.String({ maxLength: 20_000, description: "Issue description" })),
68
- projectId: t.Optional(idSchema("Project ID")),
69
- parentIssueId: t.Optional(idSchema("Parent Issue ID")),
70
- sortOrder: t.Optional(t.Integer({ minimum: 0 })),
71
- status: t.Optional(enumSchema("Issue status", ISSUE_STATUS)),
72
- priority: t.Optional(enumSchema("Issue priority", ISSUE_PRIORITY)),
73
- source: t.Optional(enumSchema("Issue source", ISSUE_SOURCE)),
74
- assigneeUserId: t.Optional(idSchema("Assignee user ID")),
75
- startAt: t.Optional(timestampSchema("ISO-8601 start timestamp")),
76
- dueAt: t.Optional(timestampSchema("ISO-8601 due timestamp")),
77
- deploymentId: t.Optional(idSchema("Deployment ID")),
78
- workspaceId: t.Optional(idSchema("Workspace ID")),
79
- sessionId: t.Optional(idSchema("Session ID")),
80
- traceId: t.Optional(idSchema("Trace ID")),
81
- fingerprint: t.Optional(t.String({ minLength: 1, maxLength: 512, pattern: "\\S" })),
82
- labels: t.Optional(t.Array(t.String({ minLength: 1, maxLength: 64, pattern: "\\S" }))),
83
- metadata: t.Optional(t.Record(t.String(), t.Unknown(), { description: "Issue metadata" })),
84
- }, STRICT_OBJECT),
82
+ inputSchema: t.Object(issueFields, STRICT_OBJECT),
85
83
  async execute(input, ctx) {
86
84
  const fields = input;
87
- if (fields.parentIssueId !== undefined &&
88
- fields.projectId === undefined) {
89
- throw new Error("projectId is required when parentIssueId is set");
90
- }
91
- assertDateOrder(fields.startAt, fields.dueAt, "Issue dueAt must not precede startAt");
85
+ assertIssueShape(fields);
92
86
  const body = await request(ctx, "/v1/issues", {
93
87
  method: "POST",
94
88
  body: JSON.stringify(fields),
@@ -96,6 +90,19 @@ export function registerIssueActions(rl) {
96
90
  return body.issue;
97
91
  },
98
92
  });
93
+ rl.registerAction("issue.report", {
94
+ access: "write",
95
+ description: "Report an Issue the way an agent should: source defaults to agent, " +
96
+ "and a repeated fingerprint folds into the existing Issue as a " +
97
+ "reported_again event instead of creating a duplicate.",
98
+ inputSchema: t.Object(issueFields, STRICT_OBJECT),
99
+ async execute(input, ctx) {
100
+ const fields = input;
101
+ assertIssueShape(fields);
102
+ const body = await request(ctx, "/v1/issues/report", { method: "POST", body: JSON.stringify(fields) });
103
+ return body.issue;
104
+ },
105
+ });
99
106
  rl.registerAction("issue.update", {
100
107
  access: "write",
101
108
  description: "Update an Issue, including Project assignment, one-level parentage, ordering, status, dates, or assignee.",
@@ -139,6 +146,66 @@ export function registerIssueActions(rl) {
139
146
  return response.event;
140
147
  },
141
148
  });
149
+ rl.registerAction("issue.resolve", {
150
+ access: "write",
151
+ description: "Resolve an Issue, optionally with a closing comment recorded on the " +
152
+ "event timeline.",
153
+ inputSchema: t.Object({
154
+ id: idSchema("Issue ID"),
155
+ comment: t.Optional(t.String({
156
+ minLength: 1,
157
+ maxLength: 20_000,
158
+ pattern: "\\S",
159
+ description: "Resolution comment",
160
+ })),
161
+ }, STRICT_OBJECT),
162
+ async execute(input, ctx) {
163
+ const { id, ...fields } = input;
164
+ const body = await request(ctx, `/v1/issues/${pathSegment(id)}/resolve`, { method: "POST", body: JSON.stringify(fields) });
165
+ return body.issue;
166
+ },
167
+ });
168
+ rl.registerAction("issue.reopen", {
169
+ access: "write",
170
+ description: "Reopen a resolved or closed Issue.",
171
+ inputSchema: t.Object({ id: idSchema("Issue ID") }, STRICT_OBJECT),
172
+ async execute(input, ctx) {
173
+ const { id } = input;
174
+ const body = await request(ctx, `/v1/issues/${pathSegment(id)}/reopen`, { method: "POST" });
175
+ return body.issue;
176
+ },
177
+ });
178
+ rl.registerAction("issue.close", {
179
+ access: "write",
180
+ description: "Close an Issue without resolving it — the outcome for work that is " +
181
+ "abandoned rather than finished.",
182
+ inputSchema: t.Object({ id: idSchema("Issue ID") }, STRICT_OBJECT),
183
+ async execute(input, ctx) {
184
+ const { id } = input;
185
+ const body = await request(ctx, `/v1/issues/${pathSegment(id)}/close`, { method: "POST" });
186
+ return body.issue;
187
+ },
188
+ });
189
+ rl.registerAction("issue.event.list", {
190
+ access: "read",
191
+ description: "List the first page of an Issue's event timeline: status changes, " +
192
+ "comments, assignment and dependency history, newest first.",
193
+ inputSchema: t.Object(eventListFields, STRICT_OBJECT),
194
+ async execute(input, ctx) {
195
+ const { id, ...pagination } = input;
196
+ const body = await request(ctx, withQuery(`/v1/issues/${pathSegment(id)}/events`, listParams(pagination)));
197
+ return body.events;
198
+ },
199
+ });
200
+ rl.registerAction("issue.event.listPage", {
201
+ access: "read",
202
+ description: "List a cursor-paginated page of an Issue's event timeline.",
203
+ inputSchema: t.Object(eventListFields, STRICT_OBJECT),
204
+ async execute(input, ctx) {
205
+ const { id, ...pagination } = input;
206
+ return request(ctx, withQuery(`/v1/issues/${pathSegment(id)}/events`, listParams(pagination)));
207
+ },
208
+ });
142
209
  rl.registerAction("issue.dependency.list", {
143
210
  access: "read",
144
211
  description: "List the first page of blocking and blocked-by links.",
@@ -148,7 +215,7 @@ export function registerIssueActions(rl) {
148
215
  }, STRICT_OBJECT),
149
216
  async execute(input, ctx) {
150
217
  const { id, ...pagination } = input;
151
- const body = await request(ctx, withQuery(`/v1/issues/${pathSegment(id)}/dependencies`, issueListParams(pagination)));
218
+ const body = await request(ctx, withQuery(`/v1/issues/${pathSegment(id)}/dependencies`, listParams(pagination)));
152
219
  return body.dependencies;
153
220
  },
154
221
  });
@@ -162,7 +229,7 @@ export function registerIssueActions(rl) {
162
229
  }, STRICT_OBJECT),
163
230
  async execute(input, ctx) {
164
231
  const { id, ...pagination } = input;
165
- return request(ctx, withQuery(`/v1/issues/${pathSegment(id)}/dependencies`, issueListParams(pagination)));
232
+ return request(ctx, withQuery(`/v1/issues/${pathSegment(id)}/dependencies`, listParams(pagination)));
166
233
  },
167
234
  });
168
235
  rl.registerAction("issue.dependency.add", {
@@ -185,16 +252,23 @@ export function registerIssueActions(rl) {
185
252
  access: "write",
186
253
  description: "Remove an Issue dependency link.",
187
254
  inputSchema: t.Object({
188
- issueId: idSchema("Issue ID"),
255
+ id: idSchema("Issue ID"),
189
256
  dependencyId: idSchema("Dependency ID"),
190
257
  }, STRICT_OBJECT),
191
258
  async execute(input, ctx) {
192
- const { issueId, dependencyId } = input;
193
- await request(ctx, `/v1/issues/${pathSegment(issueId)}/dependencies/${pathSegment(dependencyId)}`, { method: "DELETE" });
259
+ const { id, dependencyId } = input;
260
+ await request(ctx, `/v1/issues/${pathSegment(id)}/dependencies/${pathSegment(dependencyId)}`, { method: "DELETE" });
194
261
  return { removed: true };
195
262
  },
196
263
  });
197
264
  }
265
+ /** Shape rules the API enforces server-side, caught before the round-trip. */
266
+ function assertIssueShape(fields) {
267
+ if (fields.parentIssueId !== undefined && fields.projectId === undefined) {
268
+ throw new Error("projectId is required when parentIssueId is set");
269
+ }
270
+ assertDateOrder(fields.startAt, fields.dueAt, "Issue dueAt must not precede startAt");
271
+ }
198
272
  function assertDateOrder(start, end, message) {
199
273
  if (typeof start === "string" && typeof end === "string" && start > end) {
200
274
  throw new Error(message);
@@ -1,5 +1,5 @@
1
1
  import * as t from "typebox";
2
- import { cursorSchema, enumSchema, idSchema, PROJECT_STATUS, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, } from "./shared.js";
2
+ import { cursorSchema, enumSchema, idSchema, listParams, PROJECT_STATUS, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, } from "./shared.js";
3
3
  const projectListFields = {
4
4
  status: t.Optional(enumSchema("Project status", PROJECT_STATUS)),
5
5
  leadUserId: t.Optional(idSchema("Lead user ID")),
@@ -7,14 +7,6 @@ const projectListFields = {
7
7
  cursor: t.Optional(cursorSchema()),
8
8
  limit: t.Optional(t.Integer({ minimum: 1, maximum: 100 })),
9
9
  };
10
- function listParams(input) {
11
- const params = new URLSearchParams();
12
- for (const [key, value] of Object.entries((input ?? {}))) {
13
- if (value !== undefined)
14
- params.set(key, String(value));
15
- }
16
- return params;
17
- }
18
10
  export function registerProjectActions(rl) {
19
11
  rl.registerAction("project.list", {
20
12
  access: "read",
@@ -1,8 +1,4 @@
1
- import { baseUrl, pathSegment } from "../../_shared/shiftCloud.js";
2
- export { baseUrl, cursorSchema, enumDescription, enumSchema, idSchema, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, } from "../../_shared/shiftCloud.js";
3
- export function pageRenderUrl(organizationId, slug) {
4
- return new URL(`/pages/${pathSegment(organizationId)}/${pathSegment(slug)}`, baseUrl()).toString();
5
- }
1
+ export { cursorSchema, enumDescription, enumSchema, idSchema, listParams, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, withQuery, } from "../../_shared/shiftCloud.js";
6
2
  export const ISSUE_STATUS = [
7
3
  "backlog",
8
4
  "todo",
@@ -45,7 +41,3 @@ export const ISSUE_VIEW_SORT = [
45
41
  "dueAt",
46
42
  "updatedAt",
47
43
  ];
48
- export const PAGE_STATUS = ["draft", "published", "archived"];
49
- export const PAGE_VISIBILITY = ["org", "invited"];
50
- export const TRANSCRIPTION_LANGUAGE = ["auto", "en", "he"];
51
- export const TRANSCRIPT_FORMAT = ["txt", "srt", "vtt", "json"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runline",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Code mode for agents \u2014 turn any API or command into a callable action",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",