openclaw-glance-plugin 0.1.2 → 0.1.4

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,160 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, open, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+
6
+ function isProcessAlive(pid) {
7
+ if (!Number.isInteger(pid) || pid <= 0) {
8
+ return false;
9
+ }
10
+ try {
11
+ process.kill(pid, 0);
12
+ return true;
13
+ } catch (_err) {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ export class SingleActiveConflictError extends Error {
19
+ constructor(message, owner) {
20
+ super(message);
21
+ this.name = 'SingleActiveConflictError';
22
+ this.code = 'E_SINGLE_ACTIVE_CONFLICT';
23
+ this.owner = owner;
24
+ }
25
+ }
26
+
27
+ export class ProcessLock {
28
+ constructor({
29
+ lockDir = path.join(process.cwd(), '.openclaw-locks'),
30
+ key,
31
+ heartbeatMs = 5000,
32
+ staleMs = 15000,
33
+ now = () => Date.now()
34
+ }) {
35
+ if (!key) throw new Error('lock key is required');
36
+ this.key = key;
37
+ this.lockDir = lockDir;
38
+ this.heartbeatMs = heartbeatMs;
39
+ this.staleMs = staleMs;
40
+ this.now = now;
41
+ this.heartbeatTimer = null;
42
+ this.held = false;
43
+ this.startedAt = null;
44
+ }
45
+
46
+ static normalizeKey(raw) {
47
+ return String(raw || '')
48
+ .trim()
49
+ .toLowerCase()
50
+ .replace(/[^a-z0-9._-]/g, '_')
51
+ .slice(0, 120);
52
+ }
53
+
54
+ static buildLockKey(baseWsUrl, token) {
55
+ const tokenHash = createHash('sha256').update(String(token || '')).digest('hex').slice(0, 16);
56
+ return ProcessLock.normalizeKey(`${baseWsUrl || ''}_${tokenHash}`);
57
+ }
58
+
59
+ get lockFile() {
60
+ return path.join(this.lockDir, `${this.key}.lock.json`);
61
+ }
62
+
63
+ async acquire() {
64
+ await mkdir(this.lockDir, { recursive: true });
65
+ const maxAttempts = 4;
66
+ let attempt = 0;
67
+ while (attempt < maxAttempts) {
68
+ attempt += 1;
69
+ this.startedAt = this.startedAt || this.now();
70
+ try {
71
+ await this._createLockFileExclusive();
72
+ this.held = true;
73
+ this._startHeartbeat();
74
+ return;
75
+ } catch (err) {
76
+ if (err?.code !== 'EEXIST') {
77
+ throw err;
78
+ }
79
+ const previous = await this._readLockRecord();
80
+ if (previous && this._isRecordActive(previous)) {
81
+ throw new SingleActiveConflictError('connection already owned by another process', previous);
82
+ }
83
+ await rm(this.lockFile, { force: true }).catch(() => {});
84
+ }
85
+ }
86
+ throw new Error(`failed to acquire lock after ${maxAttempts} attempts`);
87
+ }
88
+
89
+ async release() {
90
+ this._clearHeartbeat();
91
+ this.held = false;
92
+ await rm(this.lockFile, { force: true });
93
+ }
94
+
95
+ async _readLockRecord() {
96
+ try {
97
+ const raw = await readFile(this.lockFile, 'utf8');
98
+ return JSON.parse(raw);
99
+ } catch (err) {
100
+ if (err?.code === 'ENOENT') {
101
+ return null;
102
+ }
103
+ if (err instanceof SyntaxError) {
104
+ const invalidError = new Error('invalid lock record');
105
+ invalidError.code = 'E_INVALID_LOCK_RECORD';
106
+ throw invalidError;
107
+ }
108
+ throw err;
109
+ }
110
+ }
111
+
112
+ _isRecordActive(record) {
113
+ const heartbeatAt = Number(record?.heartbeatAt || 0);
114
+ const fresh = this.now() - heartbeatAt <= this.staleMs;
115
+ const alive = isProcessAlive(Number(record?.pid));
116
+ return Boolean(fresh && alive);
117
+ }
118
+
119
+ async _writeRecord() {
120
+ const body = {
121
+ key: this.key,
122
+ pid: process.pid,
123
+ startedAt: this.startedAt || this.now(),
124
+ heartbeatAt: this.now()
125
+ };
126
+ await writeFile(this.lockFile, JSON.stringify(body), 'utf8');
127
+ }
128
+
129
+ async _createLockFileExclusive() {
130
+ const body = JSON.stringify({
131
+ key: this.key,
132
+ pid: process.pid,
133
+ startedAt: this.startedAt || this.now(),
134
+ heartbeatAt: this.now()
135
+ });
136
+ const handle = await open(this.lockFile, 'wx');
137
+ try {
138
+ await handle.writeFile(body, 'utf8');
139
+ } finally {
140
+ await handle.close();
141
+ }
142
+ }
143
+
144
+ _startHeartbeat() {
145
+ this._clearHeartbeat();
146
+ this.heartbeatTimer = setInterval(() => {
147
+ if (!this.held) return;
148
+ this._writeRecord().catch(() => {
149
+ // ignore heartbeat write errors
150
+ });
151
+ }, this.heartbeatMs);
152
+ }
153
+
154
+ _clearHeartbeat() {
155
+ if (this.heartbeatTimer) {
156
+ clearInterval(this.heartbeatTimer);
157
+ this.heartbeatTimer = null;
158
+ }
159
+ }
160
+ }