pi-para 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +160 -0
- package/CHANGELOG.md +51 -0
- package/CONFIGURATION.md +243 -0
- package/README.md +140 -237
- package/SETUP.md +196 -112
- package/STRUCTURE.md +140 -0
- package/package.json +86 -18
- package/scripts/build-assets.mjs +29 -0
- package/scripts/install.ps1 +55 -0
- package/scripts/install.sh +63 -0
- package/scripts/smoke-install.mjs +63 -0
- package/skills/para/SKILL.md +1 -1
- package/skills/setup/SKILL.md +1 -1
- package/src/atomic-write.ts +17 -0
- package/src/capture.ts +4 -4
- package/src/cli-legacy.ts +43 -0
- package/src/cli.ts +133 -28
- package/src/commands.ts +203 -243
- package/src/config.ts +355 -0
- package/src/context.ts +29 -14
- package/src/credentials.ts +100 -0
- package/src/daemon.ts +1 -1
- package/src/doctor.ts +209 -0
- package/src/gepa/index.ts +1 -1
- package/src/index.ts +119 -40
- package/src/maintainer.ts +4 -4
- package/src/model-resolver.ts +96 -0
- package/src/paths.ts +48 -0
- package/src/processor.ts +4 -4
- package/src/qmd-providers.ts +63 -0
- package/src/query.ts +15 -1
- package/src/repair.ts +49 -0
- package/src/scheduler/controls.ts +125 -0
- package/src/scheduler/index.ts +210 -0
- package/src/scheduler/leases.ts +98 -0
- package/src/scheduler/session-capture.ts +146 -0
- package/src/scheduler/state.ts +304 -0
- package/src/session-tools.ts +1 -1
- package/src/settings.ts +126 -0
- package/src/setup.ts +116 -0
- package/src/status.ts +115 -0
- package/src/store.ts +116 -31
- package/src/summarize.ts +72 -16
- package/src/templates/prompts.ts +1 -1
- package/src/tools.ts +280 -90
- package/src/wiki-tool-guidance.ts +84 -0
- package/src/wiki.ts +98 -26
- package/dist/capture.d.ts +0 -34
- package/dist/capture.d.ts.map +0 -1
- package/dist/capture.js +0 -461
- package/dist/capture.js.map +0 -1
- package/dist/cli.d.ts +0 -15
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -405
- package/dist/cli.js.map +0 -1
- package/dist/commands.d.ts +0 -15
- package/dist/commands.d.ts.map +0 -1
- package/dist/commands.js +0 -786
- package/dist/commands.js.map +0 -1
- package/dist/context.d.ts +0 -61
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js +0 -289
- package/dist/context.js.map +0 -1
- package/dist/daemon.d.ts +0 -49
- package/dist/daemon.d.ts.map +0 -1
- package/dist/daemon.js +0 -226
- package/dist/daemon.js.map +0 -1
- package/dist/frontmatter.d.ts +0 -59
- package/dist/frontmatter.d.ts.map +0 -1
- package/dist/frontmatter.js +0 -170
- package/dist/frontmatter.js.map +0 -1
- package/dist/gepa/dataset.d.ts +0 -36
- package/dist/gepa/dataset.d.ts.map +0 -1
- package/dist/gepa/dataset.js +0 -148
- package/dist/gepa/dataset.js.map +0 -1
- package/dist/gepa/evaluator.d.ts +0 -51
- package/dist/gepa/evaluator.d.ts.map +0 -1
- package/dist/gepa/evaluator.js +0 -317
- package/dist/gepa/evaluator.js.map +0 -1
- package/dist/gepa/index.d.ts +0 -66
- package/dist/gepa/index.d.ts.map +0 -1
- package/dist/gepa/index.js +0 -240
- package/dist/gepa/index.js.map +0 -1
- package/dist/gepa/llm.d.ts +0 -35
- package/dist/gepa/llm.d.ts.map +0 -1
- package/dist/gepa/llm.js +0 -268
- package/dist/gepa/llm.js.map +0 -1
- package/dist/gepa/optimized/capture-prompt.txt +0 -65
- package/dist/gepa/optimizer.d.ts +0 -29
- package/dist/gepa/optimizer.d.ts.map +0 -1
- package/dist/gepa/optimizer.js +0 -249
- package/dist/gepa/optimizer.js.map +0 -1
- package/dist/gepa/targets.d.ts +0 -39
- package/dist/gepa/targets.d.ts.map +0 -1
- package/dist/gepa/targets.js +0 -326
- package/dist/gepa/targets.js.map +0 -1
- package/dist/gepa/types.d.ts +0 -168
- package/dist/gepa/types.d.ts.map +0 -1
- package/dist/gepa/types.js +0 -124
- package/dist/gepa/types.js.map +0 -1
- package/dist/index.d.ts +0 -21
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -294
- package/dist/index.js.map +0 -1
- package/dist/ingest.d.ts +0 -88
- package/dist/ingest.d.ts.map +0 -1
- package/dist/ingest.js +0 -105
- package/dist/ingest.js.map +0 -1
- package/dist/link-discovery.d.ts +0 -40
- package/dist/link-discovery.d.ts.map +0 -1
- package/dist/link-discovery.js +0 -122
- package/dist/link-discovery.js.map +0 -1
- package/dist/link-utils.d.ts +0 -30
- package/dist/link-utils.d.ts.map +0 -1
- package/dist/link-utils.js +0 -145
- package/dist/link-utils.js.map +0 -1
- package/dist/lint.d.ts +0 -39
- package/dist/lint.d.ts.map +0 -1
- package/dist/lint.js +0 -827
- package/dist/lint.js.map +0 -1
- package/dist/maintainer.d.ts +0 -24
- package/dist/maintainer.d.ts.map +0 -1
- package/dist/maintainer.js +0 -358
- package/dist/maintainer.js.map +0 -1
- package/dist/processor.d.ts +0 -14
- package/dist/processor.d.ts.map +0 -1
- package/dist/processor.js +0 -361
- package/dist/processor.js.map +0 -1
- package/dist/query.d.ts +0 -69
- package/dist/query.d.ts.map +0 -1
- package/dist/query.js +0 -107
- package/dist/query.js.map +0 -1
- package/dist/raw.d.ts +0 -57
- package/dist/raw.d.ts.map +0 -1
- package/dist/raw.js +0 -219
- package/dist/raw.js.map +0 -1
- package/dist/redact.d.ts +0 -21
- package/dist/redact.d.ts.map +0 -1
- package/dist/redact.js +0 -143
- package/dist/redact.js.map +0 -1
- package/dist/scope.d.ts +0 -54
- package/dist/scope.d.ts.map +0 -1
- package/dist/scope.js +0 -161
- package/dist/scope.js.map +0 -1
- package/dist/scripts/bulk-link-fix.d.ts +0 -9
- package/dist/scripts/bulk-link-fix.d.ts.map +0 -1
- package/dist/scripts/bulk-link-fix.js +0 -106
- package/dist/scripts/bulk-link-fix.js.map +0 -1
- package/dist/session-tools.d.ts +0 -33
- package/dist/session-tools.d.ts.map +0 -1
- package/dist/session-tools.js +0 -211
- package/dist/session-tools.js.map +0 -1
- package/dist/state.d.ts +0 -54
- package/dist/state.d.ts.map +0 -1
- package/dist/state.js +0 -176
- package/dist/state.js.map +0 -1
- package/dist/store.d.ts +0 -56
- package/dist/store.d.ts.map +0 -1
- package/dist/store.js +0 -227
- package/dist/store.js.map +0 -1
- package/dist/summarize.d.ts +0 -56
- package/dist/summarize.d.ts.map +0 -1
- package/dist/summarize.js +0 -231
- package/dist/summarize.js.map +0 -1
- package/dist/tag-registry.d.ts +0 -56
- package/dist/tag-registry.d.ts.map +0 -1
- package/dist/tag-registry.js +0 -146
- package/dist/tag-registry.js.map +0 -1
- package/dist/templates/prompts.d.ts +0 -63
- package/dist/templates/prompts.d.ts.map +0 -1
- package/dist/templates/prompts.js +0 -260
- package/dist/templates/prompts.js.map +0 -1
- package/dist/tools.d.ts +0 -26
- package/dist/tools.d.ts.map +0 -1
- package/dist/tools.js +0 -1033
- package/dist/tools.js.map +0 -1
- package/dist/watcher.d.ts +0 -25
- package/dist/watcher.d.ts.map +0 -1
- package/dist/watcher.js +0 -84
- package/dist/watcher.js.map +0 -1
- package/dist/webui/client/dist/index.html +0 -1885
- package/dist/webui/server.d.ts +0 -31
- package/dist/webui/server.d.ts.map +0 -1
- package/dist/webui/server.js +0 -566
- package/dist/webui/server.js.map +0 -1
- package/dist/wiki.d.ts +0 -103
- package/dist/wiki.d.ts.map +0 -1
- package/dist/wiki.js +0 -428
- package/dist/wiki.js.map +0 -1
- package/scripts/gepa/__pycache__/dataset.cpython-313.pyc +0 -0
- package/scripts/gepa/__pycache__/lm_providers.cpython-313.pyc +0 -0
- package/scripts/gepa/__pycache__/metric.cpython-313.pyc +0 -0
- package/scripts/gepa/__pycache__/program.cpython-313.pyc +0 -0
- package/scripts/gepa/__pycache__/wiki_reader.cpython-313.pyc +0 -0
- package/scripts/gepa/uv.lock +0 -2241
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { CollectionConfig } from "qmd-engine";
|
|
2
|
+
import type { ParaUserConfig, ProviderCredentialRef } from "./config.js";
|
|
3
|
+
import { resolveCredentialRef, type CredentialRef } from "./credentials.js";
|
|
4
|
+
|
|
5
|
+
export type QmdProvidersConfig = NonNullable<CollectionConfig["providers"]>;
|
|
6
|
+
|
|
7
|
+
export interface BuildQmdProvidersOptions {
|
|
8
|
+
secretsPath?: string;
|
|
9
|
+
authStorage?: { getApiKey(provider: string): Promise<string | undefined> };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function buildQmdProvidersFromParaConfig(
|
|
13
|
+
config: ParaUserConfig,
|
|
14
|
+
opts: BuildQmdProvidersOptions = {},
|
|
15
|
+
): Promise<QmdProvidersConfig | undefined> {
|
|
16
|
+
if (config.qmd.providerConfig !== "pi-para-profiles") return undefined;
|
|
17
|
+
|
|
18
|
+
const providers: QmdProvidersConfig = {};
|
|
19
|
+
|
|
20
|
+
if (config.qmd.embedEnabled && config.qmd.embedding) {
|
|
21
|
+
providers.embed = await toQmdEndpoint(config.qmd.embedding, "embed", opts);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (config.qmd.rerank) {
|
|
25
|
+
providers.rerank = await toQmdEndpoint(config.qmd.rerank, "rerank", opts);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return Object.keys(providers).length > 0 ? providers : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function toQmdEndpoint(
|
|
32
|
+
profile: ProviderCredentialRef,
|
|
33
|
+
role: "embed" | "rerank",
|
|
34
|
+
opts: BuildQmdProvidersOptions,
|
|
35
|
+
): Promise<NonNullable<QmdProvidersConfig[typeof role]>> {
|
|
36
|
+
const resolved = await resolveCredentialRef(profile.credentialRef as CredentialRef, opts);
|
|
37
|
+
const endpoint: NonNullable<QmdProvidersConfig[typeof role]> = {
|
|
38
|
+
url: profile.baseUrl || defaultBaseUrl(profile.provider, role),
|
|
39
|
+
model: profile.model,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (profile.apiFormat === "openai" || profile.apiFormat === "anthropic") {
|
|
43
|
+
endpoint.api = profile.apiFormat;
|
|
44
|
+
}
|
|
45
|
+
if (resolved.ok && resolved.value) {
|
|
46
|
+
endpoint.key = resolved.value;
|
|
47
|
+
}
|
|
48
|
+
if (role === "embed" && profile.dims) {
|
|
49
|
+
(endpoint as NonNullable<QmdProvidersConfig["embed"]>).dims = profile.dims;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return endpoint;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function defaultBaseUrl(provider: string, role: "embed" | "rerank"): string | undefined {
|
|
56
|
+
const normalized = provider.toLowerCase();
|
|
57
|
+
if (normalized === "openai") return "https://api.openai.com/v1";
|
|
58
|
+
if (normalized === "openrouter") return "https://openrouter.ai/api/v1";
|
|
59
|
+
if (normalized === "jina" && role === "rerank") return "https://api.jina.ai/v1";
|
|
60
|
+
if (normalized === "voyage") return "https://api.voyageai.com/v1";
|
|
61
|
+
if (normalized === "cohere") return "https://api.cohere.com/v2";
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
package/src/query.ts
CHANGED
|
@@ -79,7 +79,7 @@ export async function queryWiki(
|
|
|
79
79
|
effectiveScope = scope;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
let results = await searchWiki(store, options.query, {
|
|
83
83
|
scope: effectiveScope,
|
|
84
84
|
category: options.category,
|
|
85
85
|
limit,
|
|
@@ -87,6 +87,20 @@ export async function queryWiki(
|
|
|
87
87
|
graphBoost: options.graphBoost,
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
// If QMD's BM25 index is stale, freshly written/readable pages can return
|
|
91
|
+
// as zero search results. On a miss, refresh the filesystem index once and
|
|
92
|
+
// retry before telling the agent the wiki lacks matching content.
|
|
93
|
+
if (results.length === 0) {
|
|
94
|
+
await store.update();
|
|
95
|
+
results = await searchWiki(store, options.query, {
|
|
96
|
+
scope: effectiveScope,
|
|
97
|
+
category: options.category,
|
|
98
|
+
limit,
|
|
99
|
+
includeArchives,
|
|
100
|
+
graphBoost: options.graphBoost,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
90
104
|
return {
|
|
91
105
|
results,
|
|
92
106
|
scopeUsed: effectiveScope ?? scope,
|
package/src/repair.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync, chmodSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const GENERATED_STATE_GITIGNORE_PATTERNS = [
|
|
6
|
+
".qmd.sqlite*",
|
|
7
|
+
".daemon.sqlite*",
|
|
8
|
+
".pi-para.sqlite*",
|
|
9
|
+
"gepa/input/",
|
|
10
|
+
"gepa/output/",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export interface GitignoreRepairResult {
|
|
14
|
+
path: string;
|
|
15
|
+
changed: boolean;
|
|
16
|
+
added: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function missingGeneratedStateGitignorePatterns(wikiDir: string): string[] {
|
|
20
|
+
const path = join(wikiDir, ".gitignore");
|
|
21
|
+
const existing = existsSync(path) ? readFileSync(path, "utf-8") : "";
|
|
22
|
+
const lines = existing.split(/\r?\n/);
|
|
23
|
+
return GENERATED_STATE_GITIGNORE_PATTERNS.filter((pattern) => !lines.includes(pattern));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function ensureGeneratedStateGitignore(wikiDir: string): Promise<GitignoreRepairResult> {
|
|
27
|
+
const path = join(wikiDir, ".gitignore");
|
|
28
|
+
await mkdir(dirname(path), { recursive: true });
|
|
29
|
+
const existing = existsSync(path) ? readFileSync(path, "utf-8") : "";
|
|
30
|
+
const added = missingGeneratedStateGitignorePatterns(wikiDir);
|
|
31
|
+
if (added.length === 0) return { path, changed: false, added: [] };
|
|
32
|
+
|
|
33
|
+
const prefix = existing.trimEnd().length > 0 ? `${existing.trimEnd()}\n` : "";
|
|
34
|
+
const block = `${prefix}${prefix ? "\n" : ""}# pi-para generated state (do not version)\n${added.join("\n")}\n`;
|
|
35
|
+
await writeFile(path, block, "utf-8");
|
|
36
|
+
return { path, changed: true, added };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function fixSecretPermissions(secretsPath: string): boolean {
|
|
40
|
+
if (!existsSync(secretsPath)) return false;
|
|
41
|
+
const before = readMode(secretsPath);
|
|
42
|
+
if ((before & 0o077) === 0) return false;
|
|
43
|
+
chmodSync(secretsPath, 0o600);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readMode(path: string): number {
|
|
48
|
+
return existsSync(path) ? (statSync(path).mode & 0o777) : 0;
|
|
49
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { getParaPaths } from "../paths.js";
|
|
2
|
+
import { StateDB } from "../state.js";
|
|
3
|
+
import { WikiScheduler } from "./index.js";
|
|
4
|
+
import { SchedulerStateDB, type QueueItem, type QueueStatus, type SchedulerHistoryItem } from "./state.js";
|
|
5
|
+
import { enqueueCompletedSessions, readCompletedSessionRegistry } from "./session-capture.js";
|
|
6
|
+
|
|
7
|
+
export interface QueueCaptureRecentOptions {
|
|
8
|
+
wikiDir: string;
|
|
9
|
+
dbPath?: string;
|
|
10
|
+
hours?: number;
|
|
11
|
+
now?: Date;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface QueueCaptureRecentResult {
|
|
15
|
+
scanned: number;
|
|
16
|
+
eligible: number;
|
|
17
|
+
queuedIds: number[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function queueCaptureRecent(options: QueueCaptureRecentOptions): Promise<QueueCaptureRecentResult> {
|
|
21
|
+
const now = options.now ?? new Date();
|
|
22
|
+
const hours = options.hours ?? 24;
|
|
23
|
+
const cutoff = now.getTime() - hours * 60 * 60 * 1000;
|
|
24
|
+
const entries = await readCompletedSessionRegistry(options.wikiDir);
|
|
25
|
+
const eligible = entries.filter((entry) => {
|
|
26
|
+
const ts = Date.parse(entry.timestamp);
|
|
27
|
+
return Number.isFinite(ts) && ts >= cutoff;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const dbPath = options.dbPath ?? getParaPaths({ wikiDir: options.wikiDir }).schedulerDbPath;
|
|
31
|
+
const scheduler = new WikiScheduler({ wikiDir: options.wikiDir, dbPath, enabled: true });
|
|
32
|
+
const stateDb = new StateDB(options.wikiDir);
|
|
33
|
+
try {
|
|
34
|
+
const queuedIds = enqueueCompletedSessions(scheduler, eligible, { stateDb });
|
|
35
|
+
return { scanned: entries.length, eligible: eligible.length, queuedIds };
|
|
36
|
+
} finally {
|
|
37
|
+
stateDb.close();
|
|
38
|
+
scheduler.stop();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function listSchedulerTasks(
|
|
43
|
+
wikiDir: string,
|
|
44
|
+
opts: { dbPath?: string; status?: QueueStatus } = {},
|
|
45
|
+
): QueueItem[] {
|
|
46
|
+
const db = new SchedulerStateDB(opts.dbPath ?? getParaPaths({ wikiDir }).schedulerDbPath);
|
|
47
|
+
try {
|
|
48
|
+
return db.list(opts.status);
|
|
49
|
+
} finally {
|
|
50
|
+
db.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function retryFailedSchedulerTasks(
|
|
55
|
+
wikiDir: string,
|
|
56
|
+
opts: { dbPath?: string; taskName?: string; now?: Date } = {},
|
|
57
|
+
): number {
|
|
58
|
+
const db = new SchedulerStateDB(opts.dbPath ?? getParaPaths({ wikiDir }).schedulerDbPath);
|
|
59
|
+
try {
|
|
60
|
+
return db.retryFailed(opts.taskName, opts.now);
|
|
61
|
+
} finally {
|
|
62
|
+
db.close();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getSchedulerTask(
|
|
67
|
+
wikiDir: string,
|
|
68
|
+
id: number,
|
|
69
|
+
opts: { dbPath?: string } = {},
|
|
70
|
+
): QueueItem | null {
|
|
71
|
+
const db = new SchedulerStateDB(opts.dbPath ?? getParaPaths({ wikiDir }).schedulerDbPath);
|
|
72
|
+
try {
|
|
73
|
+
return db.get(id);
|
|
74
|
+
} finally {
|
|
75
|
+
db.close();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function listSchedulerHistory(
|
|
80
|
+
wikiDir: string,
|
|
81
|
+
opts: { dbPath?: string; taskName?: string; limit?: number } = {},
|
|
82
|
+
): SchedulerHistoryItem[] {
|
|
83
|
+
const db = new SchedulerStateDB(opts.dbPath ?? getParaPaths({ wikiDir }).schedulerDbPath);
|
|
84
|
+
try {
|
|
85
|
+
return db.listHistory({ taskName: opts.taskName, limit: opts.limit });
|
|
86
|
+
} finally {
|
|
87
|
+
db.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function formatQueueItems(items: QueueItem[]): string {
|
|
92
|
+
if (items.length === 0) return "No scheduler tasks queued.";
|
|
93
|
+
return items
|
|
94
|
+
.map((item) => `${item.id} ${item.status} ${item.taskName} attempts=${item.attempts} dedupe=${item.dedupeKey ?? "-"}`)
|
|
95
|
+
.join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function formatQueueCaptureRecentResult(result: QueueCaptureRecentResult): string {
|
|
99
|
+
return [
|
|
100
|
+
`Scanned ${result.scanned} completed-session entr${result.scanned === 1 ? "y" : "ies"}.`,
|
|
101
|
+
`Eligible within window: ${result.eligible}.`,
|
|
102
|
+
`Queued ${result.queuedIds.length} capture task${result.queuedIds.length === 1 ? "" : "s"}.`,
|
|
103
|
+
].join("\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function formatQueueItem(item: QueueItem | null): string {
|
|
107
|
+
if (!item) return "Scheduler task not found.";
|
|
108
|
+
return [
|
|
109
|
+
`Task ${item.id}: ${item.taskName}`,
|
|
110
|
+
`Status: ${item.status}`,
|
|
111
|
+
`Attempts: ${item.attempts}`,
|
|
112
|
+
`Available: ${item.availableAt}`,
|
|
113
|
+
`Dedupe: ${item.dedupeKey ?? "-"}`,
|
|
114
|
+
`Payload: ${JSON.stringify(item.payload, null, 2)}`,
|
|
115
|
+
].join("\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function formatSchedulerHistory(items: SchedulerHistoryItem[]): string {
|
|
119
|
+
if (items.length === 0) return "No scheduler history.";
|
|
120
|
+
return items.map((item) => {
|
|
121
|
+
const duration = item.durationMs === null ? "-" : `${item.durationMs}ms`;
|
|
122
|
+
const error = item.error ? ` error=${item.error.slice(0, 80)}` : "";
|
|
123
|
+
return `${item.id} ${item.status} ${item.taskName} duration=${duration}${error}`;
|
|
124
|
+
}).join("\n");
|
|
125
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { QMDStore } from "qmd-engine";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { gitCommit, rebuildIndex } from "../wiki.js";
|
|
4
|
+
import { reindex } from "../store.js";
|
|
5
|
+
import { getParaPaths } from "../paths.js";
|
|
6
|
+
import { acquireLease, releaseLease } from "./leases.js";
|
|
7
|
+
import { SchedulerStateDB, type QueueItem } from "./state.js";
|
|
8
|
+
|
|
9
|
+
export type SchedulerTaskHandler = (item: QueueItem, scheduler: WikiScheduler) => Promise<void>;
|
|
10
|
+
|
|
11
|
+
export interface WikiSchedulerOptions {
|
|
12
|
+
wikiDir: string;
|
|
13
|
+
dbPath?: string;
|
|
14
|
+
intervalMs?: number;
|
|
15
|
+
enabled?: boolean;
|
|
16
|
+
storeProvider?: () => QMDStore | null;
|
|
17
|
+
markDirty?: () => void;
|
|
18
|
+
handlers?: Record<string, SchedulerTaskHandler>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class WikiScheduler {
|
|
22
|
+
readonly wikiDir: string;
|
|
23
|
+
readonly holderId: string;
|
|
24
|
+
readonly state: SchedulerStateDB;
|
|
25
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
26
|
+
private running = false;
|
|
27
|
+
private stopped = false;
|
|
28
|
+
private closed = false;
|
|
29
|
+
private readonly intervalMs: number;
|
|
30
|
+
private readonly enabled: boolean;
|
|
31
|
+
private readonly storeProvider: () => QMDStore | null;
|
|
32
|
+
private readonly markDirty: () => void;
|
|
33
|
+
private handlers: Record<string, SchedulerTaskHandler>;
|
|
34
|
+
|
|
35
|
+
constructor(opts: WikiSchedulerOptions) {
|
|
36
|
+
this.wikiDir = opts.wikiDir;
|
|
37
|
+
this.holderId = `scheduler:${process.pid}:${randomUUID()}`;
|
|
38
|
+
this.state = new SchedulerStateDB(opts.dbPath ?? getParaPaths({ wikiDir: opts.wikiDir }).schedulerDbPath);
|
|
39
|
+
this.intervalMs = opts.intervalMs ?? 15 * 60_000;
|
|
40
|
+
this.enabled = opts.enabled ?? true;
|
|
41
|
+
this.storeProvider = opts.storeProvider ?? (() => null);
|
|
42
|
+
this.markDirty = opts.markDirty ?? (() => {});
|
|
43
|
+
this.handlers = {
|
|
44
|
+
"wiki-maintenance": async () => this.runWikiMaintenance(),
|
|
45
|
+
...(opts.handlers ?? {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
start(): void {
|
|
50
|
+
if (!this.enabled || this.timer || this.closed) return;
|
|
51
|
+
this.stopped = false;
|
|
52
|
+
this.state.requeueRunning();
|
|
53
|
+
void this.tick();
|
|
54
|
+
this.timer = setInterval(() => void this.tick(), this.intervalMs);
|
|
55
|
+
(this.timer as { unref?: () => void }).unref?.();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
stop(): void {
|
|
59
|
+
this.stopped = true;
|
|
60
|
+
if (this.timer) clearInterval(this.timer);
|
|
61
|
+
this.timer = null;
|
|
62
|
+
if (!this.running) this.closeState();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private closeState(): void {
|
|
66
|
+
if (this.closed) return;
|
|
67
|
+
this.closed = true;
|
|
68
|
+
this.state.close();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
enqueue(taskName: string, payload: unknown = {}, opts: { priority?: number; dedupeKey?: string; availableAt?: Date } = {}): number {
|
|
72
|
+
if (this.closed) throw new Error("Wiki scheduler is stopped.");
|
|
73
|
+
return this.state.enqueue(taskName, payload, opts);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
registerHandlers(handlers: Record<string, SchedulerTaskHandler>): void {
|
|
77
|
+
this.handlers = { ...this.handlers, ...handlers };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async tick(): Promise<number> {
|
|
81
|
+
if (!this.enabled || this.running || this.stopped || this.closed) return 0;
|
|
82
|
+
this.running = true;
|
|
83
|
+
let processed = 0;
|
|
84
|
+
try {
|
|
85
|
+
for (;;) {
|
|
86
|
+
if (this.stopped) break;
|
|
87
|
+
const item = this.state.claimNext();
|
|
88
|
+
if (!item) break;
|
|
89
|
+
await this.runItem(item);
|
|
90
|
+
processed++;
|
|
91
|
+
}
|
|
92
|
+
} finally {
|
|
93
|
+
this.running = false;
|
|
94
|
+
if (this.stopped) this.closeState();
|
|
95
|
+
}
|
|
96
|
+
return processed;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async runItem(item: QueueItem): Promise<void> {
|
|
100
|
+
const handler = this.handlers[item.taskName];
|
|
101
|
+
const started = new Date();
|
|
102
|
+
if (!handler) {
|
|
103
|
+
this.state.fail(item.id, `Unknown scheduler task: ${item.taskName}`, { now: started, maxAttempts: 1 });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
await handler(item, this);
|
|
109
|
+
if (this.stopped || this.closed) return;
|
|
110
|
+
const finished = new Date();
|
|
111
|
+
this.state.complete(item.id, finished);
|
|
112
|
+
this.state.recordHistory(item.taskName, item.payload, "done", started, finished, finished.getTime() - started.getTime(), null);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (this.stopped || this.closed) return;
|
|
115
|
+
const retryAt = new Date(Date.now() + Math.min(60_000, 1_000 * 2 ** item.attempts));
|
|
116
|
+
this.state.fail(item.id, err instanceof Error ? err.message : String(err), { retryAt, now: new Date() });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async runWikiMaintenance(): Promise<void> {
|
|
121
|
+
const leaseKey = `wiki-maintenance:${this.wikiDir}`;
|
|
122
|
+
if (!acquireLease(this.state.db, leaseKey, this.holderId, { ttlMs: 5 * 60_000 })) return;
|
|
123
|
+
try {
|
|
124
|
+
await rebuildIndex(this.wikiDir);
|
|
125
|
+
const store = this.storeProvider();
|
|
126
|
+
if (store) await reindex(store);
|
|
127
|
+
this.markDirty();
|
|
128
|
+
await gitCommit(this.wikiDir, "wiki: rebuild index and refresh search");
|
|
129
|
+
} finally {
|
|
130
|
+
releaseLease(this.state.db, leaseKey, this.holderId);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const schedulers = new Map<string, WikiScheduler>();
|
|
136
|
+
const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
137
|
+
|
|
138
|
+
export function getWikiScheduler(opts: WikiSchedulerOptions): WikiScheduler {
|
|
139
|
+
const key = opts.dbPath ?? getParaPaths({ wikiDir: opts.wikiDir }).schedulerDbPath;
|
|
140
|
+
const existing = schedulers.get(key);
|
|
141
|
+
if (existing) {
|
|
142
|
+
if (opts.handlers) existing.registerHandlers(opts.handlers);
|
|
143
|
+
return existing;
|
|
144
|
+
}
|
|
145
|
+
const scheduler = new WikiScheduler(opts);
|
|
146
|
+
schedulers.set(key, scheduler);
|
|
147
|
+
return scheduler;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function startWikiScheduler(opts: WikiSchedulerOptions): WikiScheduler {
|
|
151
|
+
const scheduler = getWikiScheduler(opts);
|
|
152
|
+
scheduler.start();
|
|
153
|
+
return scheduler;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function stopWikiScheduler(wikiDir: string, dbPath?: string): void {
|
|
157
|
+
const key = dbPath ?? getParaPaths({ wikiDir }).schedulerDbPath;
|
|
158
|
+
const timer = debounceTimers.get(key);
|
|
159
|
+
if (timer) {
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
debounceTimers.delete(key);
|
|
162
|
+
}
|
|
163
|
+
const scheduler = schedulers.get(key);
|
|
164
|
+
if (!scheduler) return;
|
|
165
|
+
scheduler.stop();
|
|
166
|
+
schedulers.delete(key);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function enqueueWikiMaintenance(
|
|
170
|
+
wikiDir: string,
|
|
171
|
+
store: QMDStore,
|
|
172
|
+
markDirty: () => void,
|
|
173
|
+
opts: { delayMs?: number; dbPath?: string } = {},
|
|
174
|
+
): number | null {
|
|
175
|
+
const key = opts.dbPath ?? getParaPaths({ wikiDir }).schedulerDbPath;
|
|
176
|
+
const existingTimer = debounceTimers.get(key);
|
|
177
|
+
if (existingTimer) clearTimeout(existingTimer);
|
|
178
|
+
|
|
179
|
+
const scheduler = getWikiScheduler({
|
|
180
|
+
wikiDir,
|
|
181
|
+
dbPath: opts.dbPath,
|
|
182
|
+
storeProvider: () => store,
|
|
183
|
+
markDirty,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const delayMs = opts.delayMs ?? 2_000;
|
|
187
|
+
if (delayMs <= 0) {
|
|
188
|
+
const id = scheduler.enqueue("wiki-maintenance", {}, { dedupeKey: "wiki-maintenance", priority: 10 });
|
|
189
|
+
void scheduler.tick();
|
|
190
|
+
return id;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const timer = setTimeout(() => {
|
|
194
|
+
debounceTimers.delete(key);
|
|
195
|
+
scheduler.enqueue("wiki-maintenance", {}, { dedupeKey: "wiki-maintenance", priority: 10 });
|
|
196
|
+
void scheduler.tick();
|
|
197
|
+
}, delayMs);
|
|
198
|
+
(timer as { unref?: () => void }).unref?.();
|
|
199
|
+
debounceTimers.set(key, timer);
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function resetSchedulersForTests(): void {
|
|
204
|
+
for (const timer of debounceTimers.values()) clearTimeout(timer);
|
|
205
|
+
debounceTimers.clear();
|
|
206
|
+
for (const [key, scheduler] of schedulers) {
|
|
207
|
+
scheduler.stop();
|
|
208
|
+
schedulers.delete(key);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
export interface LeaseRecord {
|
|
5
|
+
leaseKey: string;
|
|
6
|
+
holderId: string;
|
|
7
|
+
expiresAt: string;
|
|
8
|
+
heartbeatAt: string;
|
|
9
|
+
metadataJson: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ensureLeaseSchema(db: Database.Database): void {
|
|
13
|
+
db.exec(`
|
|
14
|
+
CREATE TABLE IF NOT EXISTS scheduler_leases (
|
|
15
|
+
lease_key TEXT PRIMARY KEY,
|
|
16
|
+
holder_id TEXT NOT NULL,
|
|
17
|
+
expires_at TEXT NOT NULL,
|
|
18
|
+
heartbeat_at TEXT NOT NULL,
|
|
19
|
+
metadata_json TEXT
|
|
20
|
+
);
|
|
21
|
+
`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createLeaseHolder(prefix = "pi-para"): string {
|
|
25
|
+
return `${prefix}:${process.pid}:${randomUUID()}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function acquireLease(
|
|
29
|
+
db: Database.Database,
|
|
30
|
+
leaseKey: string,
|
|
31
|
+
holderId: string,
|
|
32
|
+
opts: { ttlMs?: number; now?: Date; metadata?: unknown } = {},
|
|
33
|
+
): boolean {
|
|
34
|
+
ensureLeaseSchema(db);
|
|
35
|
+
const now = opts.now ?? new Date();
|
|
36
|
+
const ttlMs = opts.ttlMs ?? 2 * 60_000;
|
|
37
|
+
const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
|
|
38
|
+
const nowIso = now.toISOString();
|
|
39
|
+
const metadataJson = opts.metadata === undefined ? null : JSON.stringify(opts.metadata);
|
|
40
|
+
|
|
41
|
+
db.exec("BEGIN IMMEDIATE");
|
|
42
|
+
try {
|
|
43
|
+
const current = db.prepare("SELECT holder_id, expires_at FROM scheduler_leases WHERE lease_key = ?").get(leaseKey) as
|
|
44
|
+
| { holder_id: string; expires_at: string }
|
|
45
|
+
| undefined;
|
|
46
|
+
|
|
47
|
+
const expired = !current || Date.parse(current.expires_at) <= now.getTime();
|
|
48
|
+
const sameHolder = current?.holder_id === holderId;
|
|
49
|
+
if (!expired && !sameHolder) {
|
|
50
|
+
db.exec("ROLLBACK");
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
db.prepare(`
|
|
55
|
+
INSERT OR REPLACE INTO scheduler_leases
|
|
56
|
+
(lease_key, holder_id, expires_at, heartbeat_at, metadata_json)
|
|
57
|
+
VALUES (?, ?, ?, ?, ?)
|
|
58
|
+
`).run(leaseKey, holderId, expiresAt, nowIso, metadataJson);
|
|
59
|
+
db.exec("COMMIT");
|
|
60
|
+
return true;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
try { db.exec("ROLLBACK"); } catch {}
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function renewLease(
|
|
68
|
+
db: Database.Database,
|
|
69
|
+
leaseKey: string,
|
|
70
|
+
holderId: string,
|
|
71
|
+
opts: { ttlMs?: number; now?: Date } = {},
|
|
72
|
+
): boolean {
|
|
73
|
+
ensureLeaseSchema(db);
|
|
74
|
+
const now = opts.now ?? new Date();
|
|
75
|
+
const expiresAt = new Date(now.getTime() + (opts.ttlMs ?? 2 * 60_000)).toISOString();
|
|
76
|
+
const result = db.prepare(`
|
|
77
|
+
UPDATE scheduler_leases
|
|
78
|
+
SET expires_at = ?, heartbeat_at = ?
|
|
79
|
+
WHERE lease_key = ? AND holder_id = ?
|
|
80
|
+
`).run(expiresAt, now.toISOString(), leaseKey, holderId);
|
|
81
|
+
return result.changes === 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function releaseLease(db: Database.Database, leaseKey: string, holderId: string): boolean {
|
|
85
|
+
ensureLeaseSchema(db);
|
|
86
|
+
const result = db.prepare("DELETE FROM scheduler_leases WHERE lease_key = ? AND holder_id = ?").run(leaseKey, holderId);
|
|
87
|
+
return result.changes === 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function getLease(db: Database.Database, leaseKey: string): LeaseRecord | null {
|
|
91
|
+
ensureLeaseSchema(db);
|
|
92
|
+
const row = db.prepare(`
|
|
93
|
+
SELECT lease_key as leaseKey, holder_id as holderId, expires_at as expiresAt,
|
|
94
|
+
heartbeat_at as heartbeatAt, metadata_json as metadataJson
|
|
95
|
+
FROM scheduler_leases WHERE lease_key = ?
|
|
96
|
+
`).get(leaseKey) as LeaseRecord | undefined;
|
|
97
|
+
return row ?? null;
|
|
98
|
+
}
|