@jiggai/recipes 0.2.23 → 0.2.25

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,10 @@
1
+ /**
2
+ * Tiny, safe template renderer: replaces {{key}} with vars[key].
3
+ * No conditionals, no eval.
4
+ */
5
+ export function renderTemplate(raw: string, vars: Record<string, string>): string {
6
+ return raw.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_m, key) => {
7
+ const v = vars[key];
8
+ return typeof v === "string" ? v : "";
9
+ });
10
+ }
@@ -1,49 +1,66 @@
1
- import path from 'node:path';
2
- import fs from 'node:fs/promises';
1
+ import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+ import { fileExists } from "./fs-utils";
4
+ import { ticketStageDir, type TicketLane } from "./lanes";
3
5
 
4
- async function fileExists(p: string) {
5
- try {
6
- await fs.access(p);
7
- return true;
8
- } catch {
9
- return false;
10
- }
11
- }
12
-
13
- export type TicketLane = 'backlog' | 'in-progress' | 'testing' | 'done';
6
+ export type { TicketLane };
14
7
 
15
8
  export function laneDir(teamDir: string, lane: TicketLane) {
16
- return path.join(teamDir, 'work', lane);
9
+ return ticketStageDir(teamDir, lane);
17
10
  }
18
11
 
12
+ const LANE_SEARCH_ORDER: TicketLane[] = ["backlog", "in-progress", "testing", "done"];
13
+
19
14
  export function allLaneDirs(teamDir: string) {
20
- return [
21
- laneDir(teamDir, 'backlog'),
22
- laneDir(teamDir, 'in-progress'),
23
- laneDir(teamDir, 'testing'),
24
- laneDir(teamDir, 'done'),
25
- ];
15
+ return LANE_SEARCH_ORDER.map((lane) => ticketStageDir(teamDir, lane));
16
+ }
17
+
18
+ /**
19
+ * Compute the next ticket number (max existing + 1) by scanning ticket lane dirs.
20
+ * Used when creating new tickets (e.g. dispatch).
21
+ */
22
+ export async function computeNextTicketNumber(teamDir: string): Promise<number> {
23
+ let max = 0;
24
+ for (const lane of LANE_SEARCH_ORDER) {
25
+ const dir = ticketStageDir(teamDir, lane);
26
+ if (!(await fileExists(dir))) continue;
27
+ const files = await fs.readdir(dir);
28
+ for (const f of files) {
29
+ const m = f.match(TICKET_FILENAME_REGEX);
30
+ if (m) max = Math.max(max, Number(m[1]));
31
+ }
32
+ }
33
+ return max + 1;
26
34
  }
27
35
 
36
+ /** Regex for ticket filenames: 0001-slug.md */
37
+ export const TICKET_FILENAME_REGEX = /^(\d{4})-(.+)\.md$/;
38
+
28
39
  export function parseTicketArg(ticketArgRaw: string) {
29
40
  const raw = String(ticketArgRaw ?? "").trim();
30
41
 
31
42
  // Accept "30" as shorthand for ticket 0030.
32
43
  const padded = raw.match(/^\d+$/) && raw.length < 4 ? raw.padStart(4, "0") : raw;
33
44
 
34
- const ticketNum = padded.match(/^\d{4}$/)
35
- ? padded
36
- : (padded.match(/^(\d{4})-/)?.[1] ?? null);
45
+ const idMatch = padded.match(/^(\d{4})-/);
46
+ const ticketNum = padded.match(/^\d{4}$/) ? padded : (idMatch ? idMatch[1] : null);
37
47
 
38
48
  return { ticketArg: padded, ticketNum };
39
49
  }
40
50
 
51
+ /** Parse ticket number and slug from filename. Returns null if not a valid ticket filename. */
52
+ export function parseTicketFilename(filename: string): { ticketNumStr: string; slug: string } | null {
53
+ const m = filename.match(TICKET_FILENAME_REGEX);
54
+ if (!m) return null;
55
+ return { ticketNumStr: m[1], slug: m[2] };
56
+ }
57
+
41
58
  export async function findTicketFile(opts: {
42
59
  teamDir: string;
43
60
  ticket: string;
44
61
  lanes?: TicketLane[];
45
62
  }) {
46
- const lanes = opts.lanes ?? ['backlog', 'in-progress', 'testing', 'done'];
63
+ const lanes = opts.lanes ?? LANE_SEARCH_ORDER;
47
64
  const { ticketArg, ticketNum } = parseTicketArg(String(opts.ticket));
48
65
 
49
66
  for (const lane of lanes) {
@@ -1,39 +1,36 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
3
 
4
- import { ensureLaneDir } from './lanes';
5
-
6
- async function fileExists(p: string) {
7
- try {
8
- await fs.stat(p);
9
- return true;
10
- } catch {
11
- return false;
12
- }
13
- }
4
+ import { fileExists } from "./fs-utils";
5
+ import { ensureLaneDir } from "./lanes";
6
+ import { DEFAULT_TICKET_NUMBER } from "./constants";
7
+ import { findTicketFile as findTicketFileFromFinder } from "./ticket-finder";
8
+ import { parseTicketFilename } from "./ticket-finder";
14
9
 
15
10
  async function ensureDir(p: string) {
16
11
  await fs.mkdir(p, { recursive: true });
17
12
  }
18
13
 
14
+ function patchTicketFields(
15
+ md: string,
16
+ opts: { ownerSafe: string; status: string; assignmentRel: string }
17
+ ): string {
18
+ let out = md;
19
+ if (out.match(/^Owner:\s.*$/m)) out = out.replace(/^Owner:\s.*$/m, `Owner: ${opts.ownerSafe}`);
20
+ else out = out.replace(/^(# .+\n)/, `$1\nOwner: ${opts.ownerSafe}\n`);
21
+
22
+ if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: ${opts.status}`);
23
+ else out = out.replace(/^(# .+\n)/, `$1\nStatus: ${opts.status}\n`);
24
+
25
+ if (out.match(/^Assignment:\s.*$/m)) out = out.replace(/^Assignment:\s.*$/m, `Assignment: ${opts.assignmentRel}`);
26
+ else out = out.replace(/^Owner:.*$/m, (line) => `${line}\nAssignment: ${opts.assignmentRel}`);
27
+
28
+ return out;
29
+ }
30
+
31
+ /** Re-export for callers expecting (teamDir, ticketArg) signature. Delegates to ticket-finder. */
19
32
  export async function findTicketFile(teamDir: string, ticketArgRaw: string) {
20
- const stageDir = (stage: string) => path.join(teamDir, 'work', stage);
21
- const searchDirs = [stageDir('backlog'), stageDir('in-progress'), stageDir('testing'), stageDir('done')];
22
-
23
- const ticketArg = String(ticketArgRaw ?? '').trim();
24
- const padded = ticketArg.match(/^\d+$/) && ticketArg.length < 4 ? ticketArg.padStart(4, '0') : ticketArg;
25
- const ticketNum = padded.match(/^\d{4}$/) ? padded : (padded.match(/^(\d{4})-/)?.[1] ?? null);
26
-
27
- for (const dir of searchDirs) {
28
- if (!(await fileExists(dir))) continue;
29
- const files = await fs.readdir(dir);
30
- for (const f of files) {
31
- if (!f.endsWith('.md')) continue;
32
- if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
33
- if (!ticketNum && f.replace(/\.md$/, '') === padded) return path.join(dir, f);
34
- }
35
- }
36
- return null;
33
+ return findTicketFileFromFinder({ teamDir, ticket: ticketArgRaw });
37
34
  }
38
35
 
39
36
  export async function takeTicket(opts: { teamDir: string; ticket: string; owner?: string; overwriteAssignment: boolean }) {
@@ -50,33 +47,18 @@ export async function takeTicket(opts: { teamDir: string; ticket: string; owner?
50
47
  const filename = path.basename(srcPath);
51
48
  const destPath = path.join(inProgressDir, filename);
52
49
 
53
- const m = filename.match(/^(\d{4})-(.+)\.md$/);
54
- const ticketNumStr = m?.[1] ?? (opts.ticket.match(/^\d{4}$/) ? opts.ticket : '0000');
55
- const slug = m?.[2] ?? 'ticket';
50
+ const parsed = parseTicketFilename(filename) ?? { ticketNumStr: opts.ticket.match(/^\d{4}$/) ? opts.ticket : DEFAULT_TICKET_NUMBER, slug: "ticket" };
51
+ const { ticketNumStr, slug } = parsed;
56
52
 
57
53
  const assignmentsDir = path.join(teamDir, 'work', 'assignments');
58
54
  await ensureDir(assignmentsDir);
59
55
  const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${ownerSafe}.md`);
60
56
  const assignmentRel = path.relative(teamDir, assignmentPath);
61
57
 
62
- const patch = (md: string) => {
63
- let out = md;
64
- if (out.match(/^Owner:\s.*$/m)) out = out.replace(/^Owner:\s.*$/m, `Owner: ${ownerSafe}`);
65
- else out = out.replace(/^(# .+\n)/, `$1\nOwner: ${ownerSafe}\n`);
66
-
67
- if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, 'Status: in-progress');
68
- else out = out.replace(/^(# .+\n)/, `$1\nStatus: in-progress\n`);
69
-
70
- if (out.match(/^Assignment:\s.*$/m)) out = out.replace(/^Assignment:\s.*$/m, `Assignment: ${assignmentRel}`);
71
- else out = out.replace(/^Owner:.*$/m, (line) => `${line}\nAssignment: ${assignmentRel}`);
72
-
73
- return out;
74
- };
75
-
76
58
  const alreadyInProgress = srcPath === destPath;
77
59
 
78
60
  const md = await fs.readFile(srcPath, 'utf8');
79
- const nextMd = patch(md);
61
+ const nextMd = patchTicketFields(md, { ownerSafe, status: 'in-progress', assignmentRel });
80
62
  await fs.writeFile(srcPath, nextMd, 'utf8');
81
63
 
82
64
  if (!alreadyInProgress) {
@@ -109,33 +91,18 @@ export async function handoffTicket(opts: { teamDir: string; ticket: string; tes
109
91
  const filename = path.basename(srcPath);
110
92
  const destPath = path.join(testingDir, filename);
111
93
 
112
- const m = filename.match(/^(\d{4})-(.+)\.md$/);
113
- const ticketNumStr = m?.[1] ?? (opts.ticket.match(/^\d{4}$/) ? opts.ticket : '0000');
114
- const slug = m?.[2] ?? 'ticket';
94
+ const parsed = parseTicketFilename(filename) ?? { ticketNumStr: opts.ticket.match(/^\d{4}$/) ? opts.ticket : DEFAULT_TICKET_NUMBER, slug: "ticket" };
95
+ const { ticketNumStr, slug } = parsed;
115
96
 
116
97
  const assignmentsDir = path.join(teamDir, 'work', 'assignments');
117
98
  await ensureDir(assignmentsDir);
118
99
  const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${testerSafe}.md`);
119
100
  const assignmentRel = path.relative(teamDir, assignmentPath);
120
101
 
121
- const patch = (md: string) => {
122
- let out = md;
123
- if (out.match(/^Owner:\s.*$/m)) out = out.replace(/^Owner:\s.*$/m, `Owner: ${testerSafe}`);
124
- else out = out.replace(/^(# .+\n)/, `$1\nOwner: ${testerSafe}\n`);
125
-
126
- if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, 'Status: testing');
127
- else out = out.replace(/^(# .+\n)/, `$1\nStatus: testing\n`);
128
-
129
- if (out.match(/^Assignment:\s.*$/m)) out = out.replace(/^Assignment:\s.*$/m, `Assignment: ${assignmentRel}`);
130
- else out = out.replace(/^Owner:.*$/m, (line) => `${line}\nAssignment: ${assignmentRel}`);
131
-
132
- return out;
133
- };
134
-
135
102
  const alreadyInTesting = srcPath === destPath;
136
103
 
137
104
  const md = await fs.readFile(srcPath, 'utf8');
138
- const nextMd = patch(md);
105
+ const nextMd = patchTicketFields(md, { ownerSafe: testerSafe, status: 'testing', assignmentRel });
139
106
  await fs.writeFile(srcPath, nextMd, 'utf8');
140
107
 
141
108
  if (!alreadyInTesting) {
@@ -0,0 +1,33 @@
1
+ import path from "node:path";
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
3
+ import { ensureDir } from "./fs-utils";
4
+ import { ticketStageDir } from "./lanes";
5
+
6
+ export function resolveWorkspaceRoot(api: OpenClawPluginApi): string {
7
+ const root = api.config.agents?.defaults?.workspace;
8
+ if (!root) throw new Error("agents.defaults.workspace is not set in config");
9
+ return root;
10
+ }
11
+
12
+ export function resolveTeamDir(api: OpenClawPluginApi, teamId: string): string {
13
+ const workspaceRoot = resolveWorkspaceRoot(api);
14
+ return path.resolve(workspaceRoot, "..", "workspace-" + teamId);
15
+ }
16
+
17
+ export async function ensureTicketStageDirs(teamDir: string): Promise<void> {
18
+ await Promise.all([
19
+ ensureDir(path.join(teamDir, "work")),
20
+ ensureDir(ticketStageDir(teamDir, "backlog")),
21
+ ensureDir(ticketStageDir(teamDir, "in-progress")),
22
+ ensureDir(ticketStageDir(teamDir, "testing")),
23
+ ensureDir(ticketStageDir(teamDir, "done")),
24
+ ensureDir(ticketStageDir(teamDir, "assignments")),
25
+ ]);
26
+ }
27
+
28
+ export async function resolveTeamContext(api: OpenClawPluginApi, teamId: string): Promise<{ workspaceRoot: string; teamDir: string }> {
29
+ const workspaceRoot = resolveWorkspaceRoot(api);
30
+ const teamDir = path.resolve(workspaceRoot, "..", "workspace-" + teamId);
31
+ await ensureTicketStageDirs(teamDir);
32
+ return { workspaceRoot, teamDir };
33
+ }
@@ -17,7 +17,7 @@ export async function fetchMarketplaceRecipeMarkdown(params: {
17
17
  throw new Error(`Registry lookup failed (${metaRes.status}): ${metaUrl}\n\n${hint}`);
18
18
  }
19
19
 
20
- const metaData = (await metaRes.json()) as any;
20
+ const metaData = (await metaRes.json()) as { sourceUrl?: string };
21
21
  const recipe = metaData?.recipe;
22
22
  const sourceUrl = String(recipe?.sourceUrl ?? "").trim();
23
23
  if (!metaData?.ok || !sourceUrl) {
@@ -2,6 +2,10 @@
2
2
  // flag "file read + network send" when both patterns live in the same file.
3
3
  // This module intentionally contains the network call (fetch) but no filesystem reads.
4
4
 
5
+ export const TOOLS_INVOKE_TIMEOUT_MS = 30_000;
6
+ export const RETRY_DELAY_BASE_MS = 150;
7
+ export const GATEWAY_DEFAULT_PORT = 18789;
8
+
5
9
  export type ToolTextResult = { content?: Array<{ type: string; text?: string }> };
6
10
 
7
11
  export type ToolsInvokeRequest = {
@@ -18,47 +22,52 @@ type ToolsInvokeResponse = {
18
22
  error?: { message?: string } | string;
19
23
  };
20
24
 
21
- export async function toolsInvoke<T = unknown>(api: any, req: ToolsInvokeRequest): Promise<T> {
22
- const port = api.config.gateway?.port ?? 18789;
25
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
26
+
27
+ function parseToolsInvokeError(json: ToolsInvokeResponse, status: number): string {
28
+ const msg =
29
+ (typeof json.error === "object" && json.error?.message) ||
30
+ (typeof json.error === "string" ? json.error : null) ||
31
+ `tools/invoke failed (${status})`;
32
+ return msg;
33
+ }
34
+
35
+ async function doSingleToolsInvoke<T>(url: string, token: string, req: ToolsInvokeRequest): Promise<T> {
36
+ const ac = new AbortController();
37
+ const t = setTimeout(() => ac.abort(), TOOLS_INVOKE_TIMEOUT_MS);
38
+ const res = await fetch(url, {
39
+ method: "POST",
40
+ signal: ac.signal,
41
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
42
+ body: JSON.stringify(req),
43
+ }).finally(() => clearTimeout(t));
44
+
45
+ const json = (await res.json()) as ToolsInvokeResponse;
46
+ if (!res.ok || !json.ok) throw new Error(parseToolsInvokeError(json, res.status));
47
+ return json.result as T;
48
+ }
49
+
50
+ /**
51
+ * Invoke a tool via gateway /tools/invoke (with retries).
52
+ * @param api - OpenClaw plugin API
53
+ * @param req - Tool name, action, args, optional sessionKey
54
+ * @returns Tool result (typed via generic)
55
+ * @throws On missing token, HTTP error, or after retries
56
+ */
57
+ export async function toolsInvoke<T = unknown>(api: OpenClawPluginApi, req: ToolsInvokeRequest): Promise<T> {
58
+ const port = api.config.gateway?.port ?? GATEWAY_DEFAULT_PORT;
23
59
  const token = api.config.gateway?.auth?.token;
24
60
  if (!token) throw new Error("Missing gateway.auth.token in openclaw config (required for tools/invoke)");
25
61
 
26
- // We sometimes see transient undici network errors in the CLI environment
27
- // (ECONNRESET/ECONNREFUSED) even when the Gateway is healthy.
28
- // A small retry makes recipe cron reconciliation much less flaky.
29
62
  const url = `http://127.0.0.1:${port}/tools/invoke`;
30
-
31
63
  let lastErr: unknown = null;
64
+
32
65
  for (let attempt = 1; attempt <= 3; attempt++) {
33
66
  try {
34
- const ac = new AbortController();
35
- const timeoutMs = 30_000;
36
- const t = setTimeout(() => ac.abort(), timeoutMs);
37
-
38
- const res = await fetch(url, {
39
- method: "POST",
40
- signal: ac.signal,
41
- headers: {
42
- "content-type": "application/json",
43
- authorization: `Bearer ${token}`,
44
- },
45
- body: JSON.stringify(req),
46
- }).finally(() => clearTimeout(t));
47
-
48
- const json = (await res.json()) as ToolsInvokeResponse;
49
- if (!res.ok || !json.ok) {
50
- const msg =
51
- (typeof json.error === "object" && json.error?.message) ||
52
- (typeof json.error === "string" ? json.error : null) ||
53
- `tools/invoke failed (${res.status})`;
54
- throw new Error(msg);
55
- }
56
-
57
- return json.result as T;
67
+ return await doSingleToolsInvoke<T>(url, token, req);
58
68
  } catch (e) {
59
69
  lastErr = e;
60
- if (attempt >= 3) break;
61
- await new Promise((r) => setTimeout(r, 150 * attempt));
70
+ if (attempt < 3) await new Promise((r) => setTimeout(r, RETRY_DELAY_BASE_MS * attempt));
62
71
  }
63
72
  }
64
73